diff --git a/backend/app.js b/backend/app.js index 3900899f7..d24050737 100644 --- a/backend/app.js +++ b/backend/app.js @@ -136,6 +136,7 @@ const telegramModule = require('./modules/telegram'); const urlModule = require('./modules/url'); const usersModule = require('./modules/users'); const viewsModule = require('./modules/views'); +const matricesModule = require('./modules/matrices'); // Swagger documentation - enabled by default, protected by authentication // Mounted on /api-docs to avoid conflicts with API routes @@ -215,6 +216,7 @@ const registerApiRoutes = (basePath) => { app.use(basePath, backupModule.routes); app.use(basePath, searchModule.routes); app.use(basePath, viewsModule.routes); + app.use(basePath, matricesModule.routes); app.use(basePath, notificationsModule.routes); }; diff --git a/backend/migrations/20260223000001-create-matrices.js b/backend/migrations/20260223000001-create-matrices.js new file mode 100644 index 000000000..020918852 --- /dev/null +++ b/backend/migrations/20260223000001-create-matrices.js @@ -0,0 +1,86 @@ +'use strict'; + +const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeCreateTable(queryInterface, 'matrices', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + uid: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + project_id: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'projects', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + x_axis_label_left: { + type: Sequelize.STRING(100), + allowNull: false, + defaultValue: 'Low Effort', + }, + x_axis_label_right: { + type: Sequelize.STRING(100), + allowNull: false, + defaultValue: 'High Effort', + }, + y_axis_label_top: { + type: Sequelize.STRING(100), + allowNull: false, + defaultValue: 'High Impact', + }, + y_axis_label_bottom: { + type: Sequelize.STRING(100), + allowNull: false, + defaultValue: 'Low Impact', + }, + created_at: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + await safeAddIndex(queryInterface, 'matrices', ['user_id'], { + name: 'matrices_user_id_idx', + }); + await safeAddIndex(queryInterface, 'matrices', ['project_id'], { + name: 'matrices_project_id_idx', + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('matrices'); + }, +}; diff --git a/backend/migrations/20260223000002-create-task-matrices.js b/backend/migrations/20260223000002-create-task-matrices.js new file mode 100644 index 000000000..9b863ef46 --- /dev/null +++ b/backend/migrations/20260223000002-create-task-matrices.js @@ -0,0 +1,58 @@ +'use strict'; + +const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeCreateTable(queryInterface, 'task_matrices', { + task_id: { + type: Sequelize.INTEGER, + primaryKey: true, + references: { + model: 'tasks', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + matrix_id: { + type: Sequelize.INTEGER, + primaryKey: true, + references: { + model: 'matrices', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + quadrant_index: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + position: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + created_at: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + await safeAddIndex(queryInterface, 'task_matrices', ['matrix_id'], { + name: 'task_matrices_matrix_id_idx', + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('task_matrices'); + }, +}; diff --git a/backend/models/index.js b/backend/models/index.js index 0b0439899..707cea4ab 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -68,6 +68,8 @@ const Notification = require('./notification')(sequelize); const RecurringCompletion = require('./recurringCompletion')(sequelize); const TaskAttachment = require('./task_attachment')(sequelize); const Backup = require('./backup')(sequelize); +const Matrix = require('./matrix')(sequelize); +const TaskMatrix = require('./task_matrix')(sequelize); User.hasMany(Area, { foreignKey: 'user_id' }); Area.belongsTo(User, { foreignKey: 'user_id' }); @@ -188,6 +190,30 @@ TaskAttachment.belongsTo(Task, { foreignKey: 'task_id' }); User.hasMany(Backup, { foreignKey: 'user_id', as: 'Backups' }); Backup.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); +// Matrix associations +User.hasMany(Matrix, { foreignKey: 'user_id', as: 'Matrices' }); +Matrix.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); +Project.hasMany(Matrix, { foreignKey: 'project_id', as: 'Matrices' }); +Matrix.belongsTo(Project, { foreignKey: 'project_id', as: 'Project' }); + +Matrix.belongsToMany(Task, { + through: TaskMatrix, + foreignKey: 'matrix_id', + otherKey: 'task_id', + as: 'Tasks', +}); +Task.belongsToMany(Matrix, { + through: TaskMatrix, + foreignKey: 'task_id', + otherKey: 'matrix_id', + as: 'Matrices', +}); + +TaskMatrix.belongsTo(Task, { foreignKey: 'task_id' }); +TaskMatrix.belongsTo(Matrix, { foreignKey: 'matrix_id' }); +Task.hasMany(TaskMatrix, { foreignKey: 'task_id', as: 'TaskMatrices' }); +Matrix.hasMany(TaskMatrix, { foreignKey: 'matrix_id', as: 'TaskMatrices' }); + module.exports = { sequelize, User, @@ -208,4 +234,6 @@ module.exports = { RecurringCompletion, TaskAttachment, Backup, + Matrix, + TaskMatrix, }; diff --git a/backend/models/matrix.js b/backend/models/matrix.js new file mode 100644 index 000000000..64bac8f6f --- /dev/null +++ b/backend/models/matrix.js @@ -0,0 +1,70 @@ +const { DataTypes } = require('sequelize'); +const { uid } = require('../utils/uid'); + +module.exports = (sequelize) => { + const Matrix = sequelize.define( + 'Matrix', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + uid: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + defaultValue: () => uid(), + }, + name: { + type: DataTypes.STRING(255), + allowNull: false, + validate: { + notEmpty: true, + len: [1, 255], + }, + }, + project_id: { + type: DataTypes.INTEGER, + allowNull: true, + references: { + model: 'projects', + key: 'id', + }, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + x_axis_label_left: { + type: DataTypes.STRING(100), + allowNull: false, + defaultValue: 'Low Effort', + }, + x_axis_label_right: { + type: DataTypes.STRING(100), + allowNull: false, + defaultValue: 'High Effort', + }, + y_axis_label_top: { + type: DataTypes.STRING(100), + allowNull: false, + defaultValue: 'High Impact', + }, + y_axis_label_bottom: { + type: DataTypes.STRING(100), + allowNull: false, + defaultValue: 'Low Impact', + }, + }, + { + tableName: 'matrices', + } + ); + + return Matrix; +}; diff --git a/backend/models/task_matrix.js b/backend/models/task_matrix.js new file mode 100644 index 000000000..32085dd82 --- /dev/null +++ b/backend/models/task_matrix.js @@ -0,0 +1,47 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const TaskMatrix = sequelize.define( + 'TaskMatrix', + { + task_id: { + type: DataTypes.INTEGER, + primaryKey: true, + references: { + model: 'tasks', + key: 'id', + }, + }, + matrix_id: { + type: DataTypes.INTEGER, + primaryKey: true, + references: { + model: 'matrices', + key: 'id', + }, + }, + quadrant_index: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + validate: { + min: 0, + max: 3, + }, + }, + position: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + validate: { + min: 0, + }, + }, + }, + { + tableName: 'task_matrices', + } + ); + + return TaskMatrix; +}; diff --git a/backend/modules/matrices/controller.js b/backend/modules/matrices/controller.js new file mode 100644 index 000000000..cac89f2b8 --- /dev/null +++ b/backend/modules/matrices/controller.js @@ -0,0 +1,189 @@ +'use strict'; + +const matricesService = require('./service'); +const { UnauthorizedError } = require('../../shared/errors'); +const { getAuthenticatedUserId } = require('../../utils/request-utils'); + +/** + * Get authenticated user ID or throw UnauthorizedError. + */ +function requireUserId(req) { + const userId = getAuthenticatedUserId(req); + if (!userId) { + throw new UnauthorizedError('Authentication required'); + } + return userId; +} + +/** + * Matrices controller - handles HTTP requests/responses. + */ +const matricesController = { + /** + * GET /api/matrices + * List all matrices for the current user. + */ + async list(req, res, next) { + try { + const userId = requireUserId(req); + const result = await matricesService.getAll(userId, req.query); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/matrices/:matrixId + * Get a single matrix with tasks by quadrant. + */ + async getOne(req, res, next) { + try { + const userId = requireUserId(req); + const result = await matricesService.getById( + req.params.matrixId, + userId + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/matrices + * Create a new matrix. + */ + async create(req, res, next) { + try { + const userId = requireUserId(req); + const result = await matricesService.create(userId, req.body); + res.status(201).json(result); + } catch (error) { + next(error); + } + }, + + /** + * PUT /api/matrices/:matrixId + * Update a matrix. + */ + async update(req, res, next) { + try { + const userId = requireUserId(req); + const result = await matricesService.update( + req.params.matrixId, + userId, + req.body + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * DELETE /api/matrices/:matrixId + * Delete a matrix. + */ + async delete(req, res, next) { + try { + const userId = requireUserId(req); + const result = await matricesService.delete( + req.params.matrixId, + userId + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * PUT /api/matrices/:matrixId/tasks/:taskId + * Assign or move a task in a matrix. + */ + async assignTask(req, res, next) { + try { + const userId = requireUserId(req); + const result = await matricesService.assignTask( + req.params.matrixId, + req.params.taskId, + userId, + req.body + ); + res.status(result.created ? 201 : 200).json(result); + } catch (error) { + next(error); + } + }, + + /** + * DELETE /api/matrices/:matrixId/tasks/:taskId + * Remove a task from a matrix. + */ + async removeTask(req, res, next) { + try { + const userId = requireUserId(req); + const result = await matricesService.removeTask( + req.params.matrixId, + req.params.taskId, + userId + ); + res.json(result); + } catch (error) { + next(error); + } + }, + /** + * GET /api/tasks/:taskId/matrices + * Get all matrix placements for a specific task. + */ + async getTaskMatrices(req, res, next) { + try { + const userId = requireUserId(req); + const result = await matricesService.getTaskMatrices( + req.params.taskId, + userId + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/matrices/:matrixId/browse + * Browse available tasks filtered by source category. + */ + async browseTasks(req, res, next) { + try { + const userId = requireUserId(req); + const result = await matricesService.browseAvailableTasks( + req.params.matrixId, + userId, + req.query.source, + req.query.sourceId + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/matrices/placements + * Get all task-to-matrix placements for the authenticated user. + */ + async allPlacements(req, res, next) { + try { + const userId = requireUserId(req); + const result = await matricesService.getAllPlacements(userId); + res.json(result); + } catch (error) { + next(error); + } + }, +}; + +module.exports = matricesController; diff --git a/backend/modules/matrices/index.js b/backend/modules/matrices/index.js new file mode 100644 index 000000000..2b90e071e --- /dev/null +++ b/backend/modules/matrices/index.js @@ -0,0 +1,15 @@ +'use strict'; + +/** + * Matrices Module + * + * Handles 2×2 prioritization matrices: CRUD, task assignment, and browsing. + * + * Usage: + * const matricesModule = require('./modules/matrices'); + * app.use('/api', matricesModule.routes); + */ + +const routes = require('./routes'); + +module.exports = { routes }; diff --git a/backend/modules/matrices/repository.js b/backend/modules/matrices/repository.js new file mode 100644 index 000000000..c65f3b55f --- /dev/null +++ b/backend/modules/matrices/repository.js @@ -0,0 +1,280 @@ +'use strict'; + +const BaseRepository = require('../../shared/database/BaseRepository'); +const { + Matrix, + TaskMatrix, + Task, + Project, + Tag, + sequelize, +} = require('../../models'); +const { Op } = require('sequelize'); + +class MatricesRepository extends BaseRepository { + constructor() { + super(Matrix); + } + + /** + * Get task IDs already assigned to a matrix. + */ + async _getAssignedTaskIds(matrixId) { + const rows = await TaskMatrix.findAll({ + where: { matrix_id: matrixId }, + attributes: ['task_id'], + raw: true, + }); + return rows.map((r) => r.task_id); + } + + /** + * Find all matrices for a user, optionally filtered by project. + */ + async findAllForUser(userId, projectId) { + const where = { user_id: userId }; + if (projectId) { + where.project_id = projectId; + } + + return this.model.findAll({ + where, + include: [ + { + model: Project, + as: 'Project', + required: false, + attributes: ['id', 'uid', 'name'], + }, + ], + attributes: { + include: [ + [ + sequelize.literal( + '(SELECT COUNT(*) FROM task_matrices WHERE task_matrices.matrix_id = "Matrix"."id")' + ), + 'taskCount', + ], + ], + }, + order: [['created_at', 'DESC']], + }); + } + + /** + * Find a matrix by ID with all tasks grouped data. + */ + async findByIdWithTasks(matrixId, userId) { + const matrix = await this.model.findOne({ + where: { id: matrixId, user_id: userId }, + include: [ + { + model: Task, + as: 'Tasks', + through: { + attributes: ['quadrant_index', 'position'], + }, + include: [ + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + }, + ], + }, + { + model: Project, + as: 'Project', + required: false, + attributes: ['id', 'uid', 'name'], + }, + ], + }); + + return matrix; + } + + /** + * Find a matrix by ID scoped to user. + */ + async findByIdForUser(matrixId, userId) { + return this.model.findOne({ + where: { id: matrixId, user_id: userId }, + }); + } + + /** + * Find unassigned tasks for a matrix. + * - Project-linked: shows project tasks not yet in this matrix + * - Standalone: shows all user tasks without a project, not yet in this matrix + */ + async findUnassignedTasks(matrixId, projectId, userId) { + const taskIds = await this._getAssignedTaskIds(matrixId); + + const where = { + user_id: userId, + status: { [Op.notIn]: [2, 3, 5] }, // Exclude done, archived, cancelled + }; + + if (projectId) { + where.project_id = projectId; + } else { + where.project_id = { [Op.is]: null }; + } + + if (taskIds.length > 0) { + where.id = { [Op.notIn]: taskIds }; + } + + return Task.findAll({ + where, + include: [ + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + }, + ], + order: [['name', 'ASC']], + }); + } + + /** + * Find or create a task-matrix association. + */ + async findOrCreateTaskMatrix(taskId, matrixId, quadrantIndex, position) { + return TaskMatrix.findOrCreate({ + where: { task_id: taskId, matrix_id: matrixId }, + defaults: { + quadrant_index: quadrantIndex, + position: position || 0, + }, + }); + } + + /** + * Update an existing task-matrix association. + */ + async updateTaskMatrix(taskMatrix, data) { + return taskMatrix.update(data); + } + + /** + * Find a task-matrix association. + */ + async findTaskMatrix(taskId, matrixId) { + return TaskMatrix.findOne({ + where: { task_id: taskId, matrix_id: matrixId }, + }); + } + + /** + * Destroy a task-matrix association. + */ + async destroyTaskMatrix(taskMatrix) { + return taskMatrix.destroy(); + } + + /** + * Find all matrix placements for a task. + * Returns matrices with the task's quadrant info. + */ + async findMatricesForTask(taskId, userId) { + return TaskMatrix.findAll({ + where: { task_id: taskId }, + include: [ + { + model: Matrix, + as: 'Matrix', + where: { user_id: userId }, + include: [ + { + model: Project, + as: 'Project', + required: false, + attributes: ['id', 'uid', 'name'], + }, + ], + }, + ], + }); + } + + /** + * Browse available tasks for a matrix, filtered by source category. + * source: 'project' | 'area' | 'tag' + * sourceId: the id of the project, area, or tag + * Returns tasks NOT already placed in this matrix. + */ + async findAvailableTasksByFilter(matrixId, userId, source, sourceId) { + const taskIds = await this._getAssignedTaskIds(matrixId); + + const where = { + user_id: userId, + status: { [Op.notIn]: [2, 3, 5] }, // Exclude done, archived, cancelled + }; + + if (taskIds.length > 0) { + where.id = { [Op.notIn]: taskIds }; + } + + // Tag include may require a `where` clause to filter by tag uid. + const tagInclude = { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + }; + + switch (source) { + case 'project': + where.project_id = parseInt(sourceId, 10); + break; + case 'area': { + const areaId = parseInt(sourceId, 10); + const areaProjects = await Project.findAll({ + where: { area_id: areaId, user_id: userId }, + attributes: ['id'], + raw: true, + }); + if (areaProjects.length === 0) return []; + where.project_id = { [Op.in]: areaProjects.map((p) => p.id) }; + break; + } + case 'tag': + // sourceId is a tag uid (string) — filter via required Tag include + tagInclude.where = { uid: sourceId }; + break; + default: + return []; + } + + return Task.findAll({ + where, + include: [tagInclude], + order: [['name', 'ASC']], + limit: 200, + }); + } + + /** + * Find all task-to-matrix placements for a user. + * Returns a flat list: { task_id, matrix_id, quadrant_index, matrix_name }. + */ + async findAllPlacementsForUser(userId) { + return TaskMatrix.findAll({ + attributes: ['task_id', 'matrix_id', 'quadrant_index'], + include: [ + { + model: Matrix, + as: 'Matrix', + where: { user_id: userId }, + attributes: ['id', 'name'], + }, + ], + raw: true, + nest: true, + }); + } +} + +module.exports = new MatricesRepository(); diff --git a/backend/modules/matrices/routes.js b/backend/modules/matrices/routes.js new file mode 100644 index 000000000..b3dc3c5be --- /dev/null +++ b/backend/modules/matrices/routes.js @@ -0,0 +1,42 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const matricesController = require('./controller'); + +// All routes require authentication (handled by app.js middleware) + +// List all matrices +router.get('/matrices', matricesController.list); + +// Get all task placements for the user (bulk, for dot indicators) +router.get('/matrices/placements', matricesController.allPlacements); + +// Browse available tasks for a matrix (filtered by source category) +router.get('/matrices/:matrixId/browse', matricesController.browseTasks); + +// Get a single matrix with tasks +router.get('/matrices/:matrixId', matricesController.getOne); + +// Create a new matrix +router.post('/matrices', matricesController.create); + +// Update a matrix +router.put('/matrices/:matrixId', matricesController.update); + +// Delete a matrix +router.delete('/matrices/:matrixId', matricesController.delete); + +// Assign or move a task in a matrix +router.put('/matrices/:matrixId/tasks/:taskId', matricesController.assignTask); + +// Remove a task from a matrix +router.delete( + '/matrices/:matrixId/tasks/:taskId', + matricesController.removeTask +); + +// Get all matrix placements for a task +router.get('/tasks/:taskId/matrices', matricesController.getTaskMatrices); + +module.exports = router; diff --git a/backend/modules/matrices/service.js b/backend/modules/matrices/service.js new file mode 100644 index 000000000..26ba4a3cd --- /dev/null +++ b/backend/modules/matrices/service.js @@ -0,0 +1,408 @@ +'use strict'; + +const matricesRepository = require('./repository'); +const { + validateName, + validateAxisLabel, + validateQuadrantIndex, + validatePosition, +} = require('./validation'); +const { + NotFoundError, + ValidationError, +} = require('../../shared/errors'); +const { Task, Project } = require('../../models'); + +/** Fields that hold axis labels on a matrix. */ +const AXIS_FIELDS = [ + 'x_axis_label_left', + 'x_axis_label_right', + 'y_axis_label_top', + 'y_axis_label_bottom', +]; + +/** + * Validate axis labels and copy present fields into target object. + * When `requirePresence` is true the labels are validated even if undefined. + */ +function applyAxisLabels(source, target, requirePresence = false) { + for (const field of AXIS_FIELDS) { + if (requirePresence || source[field] !== undefined) { + validateAxisLabel(source[field], field); + if (source[field] !== undefined && source[field] !== null) { + target[field] = source[field]; + } + } + } +} + + +class MatricesService { + /** + * List all matrices for a user. + */ + async getAll(userId, query = {}) { + const { project_id } = query; + const matrices = await matricesRepository.findAllForUser( + userId, + project_id + ); + + return { + success: true, + data: matrices.map((m) => this._serializeMatrix(m)), + }; + } + + /** + * Get a single matrix with tasks grouped by quadrant. + */ + async getById(matrixId, userId) { + const matrix = + await matricesRepository.findByIdWithTasks(matrixId, userId); + + if (!matrix) { + throw new NotFoundError('Matrix not found.'); + } + + // Group tasks by quadrant + const quadrants = { 0: [], 1: [], 2: [], 3: [] }; + const tasks = matrix.Tasks || []; + for (const task of tasks) { + const qi = task.TaskMatrix?.quadrant_index ?? 0; + quadrants[qi].push(this._serializeTask(task)); + } + + // Get unassigned tasks if project-linked + const unassigned = await matricesRepository.findUnassignedTasks( + matrixId, + matrix.project_id, + userId + ); + + return { + success: true, + data: { + ...this._serializeMatrix(matrix), + quadrants, + unassigned: unassigned.map((t) => this._serializeTask(t)), + }, + }; + } + + /** + * Create a new matrix. + */ + async create(userId, data) { + const name = validateName(data.name); + + // Validate project_id if provided + if (data.project_id) { + const project = await Project.findOne({ + where: { id: data.project_id, user_id: userId }, + }); + if (!project) { + throw new NotFoundError('Project not found.'); + } + } + + const matrixData = { + name, + user_id: userId, + project_id: data.project_id || null, + }; + + applyAxisLabels(data, matrixData, true); + + const matrix = await matricesRepository.create(matrixData); + + return { + success: true, + data: this._serializeMatrix(matrix), + }; + } + + /** + * Update an existing matrix. + */ + async update(matrixId, userId, data) { + const matrix = await matricesRepository.findByIdForUser( + matrixId, + userId + ); + + if (!matrix) { + throw new NotFoundError('Matrix not found.'); + } + + const updateData = {}; + + if (data.name !== undefined) { + updateData.name = validateName(data.name); + } + + // Allow changing the linked project (or unlinking) + if (data.project_id !== undefined) { + if (data.project_id) { + const project = await Project.findOne({ + where: { id: data.project_id, user_id: userId }, + }); + if (!project) { + throw new NotFoundError('Project not found.'); + } + updateData.project_id = data.project_id; + } else { + updateData.project_id = null; + } + } + + applyAxisLabels(data, updateData); + + await matricesRepository.update(matrix, updateData); + + return { + success: true, + data: this._serializeMatrix(matrix), + }; + } + + /** + * Delete a matrix. + */ + async delete(matrixId, userId) { + const matrix = await matricesRepository.findByIdForUser( + matrixId, + userId + ); + + if (!matrix) { + throw new NotFoundError('Matrix not found.'); + } + + await matricesRepository.destroy(matrix); + + return { + success: true, + message: 'Matrix deleted successfully.', + }; + } + + /** + * Assign or move a task in a matrix. + */ + async assignTask(matrixId, taskId, userId, data) { + const quadrantIndex = validateQuadrantIndex(data.quadrant_index); + const position = validatePosition(data.position); + + // Verify matrix belongs to user + const matrix = await matricesRepository.findByIdForUser( + matrixId, + userId + ); + if (!matrix) { + throw new NotFoundError('Matrix not found.'); + } + + // Verify task belongs to user + const task = await Task.findOne({ + where: { id: taskId, user_id: userId }, + }); + if (!task) { + throw new NotFoundError('Matrix or Task not found.'); + } + + const [taskMatrix, created] = + await matricesRepository.findOrCreateTaskMatrix( + taskId, + matrixId, + quadrantIndex, + position + ); + + if (!created) { + await matricesRepository.updateTaskMatrix(taskMatrix, { + quadrant_index: quadrantIndex, + position, + }); + } + + return { + success: true, + created, + data: { + task_id: parseInt(taskId, 10), + matrix_id: parseInt(matrixId, 10), + quadrant_index: quadrantIndex, + position, + created_at: taskMatrix.created_at, + updated_at: taskMatrix.updated_at, + }, + message: created + ? 'Task added to matrix.' + : 'Task moved to new quadrant.', + }; + } + + /** + * Remove a task from a matrix. + */ + async removeTask(matrixId, taskId, userId) { + // Verify matrix belongs to user + const matrix = await matricesRepository.findByIdForUser( + matrixId, + userId + ); + if (!matrix) { + throw new NotFoundError('Matrix not found.'); + } + + const taskMatrix = await matricesRepository.findTaskMatrix( + taskId, + matrixId + ); + if (!taskMatrix) { + throw new NotFoundError('Task is not in this matrix.'); + } + + await matricesRepository.destroyTaskMatrix(taskMatrix); + + return { + success: true, + message: 'Task removed from matrix.', + }; + } + + /** + * Get all matrix placements for a task. + */ + async getTaskMatrices(taskId, userId) { + const placements = await matricesRepository.findMatricesForTask( + taskId, + userId + ); + + return { + success: true, + data: placements.map((tm) => ({ + matrix: this._serializeMatrix(tm.Matrix), + quadrant_index: tm.quadrant_index, + position: tm.position, + })), + }; + } + + /** + * Browse available tasks for a matrix, filtered by source category. + */ + async browseAvailableTasks(matrixId, userId, source, sourceId) { + if (!source || !sourceId) { + throw new ValidationError('source and sourceId are required.'); + } + const validSources = ['project', 'area', 'tag']; + if (!validSources.includes(source)) { + throw new ValidationError(`source must be one of: ${validSources.join(', ')}`); + } + + // Verify matrix belongs to user + const matrix = await matricesRepository.findByIdForUser(matrixId, userId); + if (!matrix) { + throw new NotFoundError('Matrix not found.'); + } + + const tasks = await matricesRepository.findAvailableTasksByFilter( + matrixId, userId, source, sourceId + ); + + return { + success: true, + data: tasks.map((t) => this._serializeTask(t)), + }; + } + + /** + * Get all task placements for a user (bulk, for dot indicators). + */ + async getAllPlacements(userId) { + const placements = await matricesRepository.findAllPlacementsForUser(userId); + + return { + success: true, + data: placements.map((p) => ({ + task_id: p.task_id, + matrix_id: p.matrix_id, + quadrant_index: p.quadrant_index, + matrix_name: p.Matrix ? p.Matrix.name : null, + })), + }; + } + + /** + * Serialize a matrix model instance. + */ + _serializeMatrix(matrix) { + const data = { + id: matrix.id, + uid: matrix.uid, + name: matrix.name, + project_id: matrix.project_id, + user_id: matrix.user_id, + x_axis_label_left: matrix.x_axis_label_left, + x_axis_label_right: matrix.x_axis_label_right, + y_axis_label_top: matrix.y_axis_label_top, + y_axis_label_bottom: matrix.y_axis_label_bottom, + created_at: matrix.created_at, + updated_at: matrix.updated_at, + }; + + // Include project info if eager-loaded + if (matrix.Project) { + data.project = { + id: matrix.Project.id, + uid: matrix.Project.uid, + name: matrix.Project.name, + }; + } + + // Include taskCount if computed + const taskCount = matrix.getDataValue + ? matrix.getDataValue('taskCount') + : undefined; + if (taskCount !== undefined) { + data.taskCount = parseInt(taskCount, 10); + } + + return data; + } + + /** + * Serialize a task for matrix responses. + */ + _serializeTask(task) { + const serialized = { + id: task.id, + uid: task.uid, + name: task.name, + status: task.status, + priority: task.priority, + due_date: task.due_date, + project_id: task.project_id, + tags: (task.Tags || []).map((t) => ({ + id: t.id, + uid: t.uid, + name: t.name, + })), + }; + + // Include join table data if present + if (task.TaskMatrix) { + serialized.TaskMatrix = { + quadrant_index: task.TaskMatrix.quadrant_index, + position: task.TaskMatrix.position, + }; + } + + return serialized; + } +} + +module.exports = new MatricesService(); diff --git a/backend/modules/matrices/validation.js b/backend/modules/matrices/validation.js new file mode 100644 index 000000000..8cfd7ad21 --- /dev/null +++ b/backend/modules/matrices/validation.js @@ -0,0 +1,73 @@ +'use strict'; + +const { ValidationError } = require('../../shared/errors'); + +/** + * Validate matrix name. + */ +function validateName(name) { + if (!name || typeof name !== 'string' || !name.trim()) { + throw new ValidationError("Validation error: 'name' is required."); + } + if (name.trim().length > 255) { + throw new ValidationError( + 'Matrix name must be 255 characters or less.' + ); + } + return name.trim(); +} + +/** + * Validate axis label. + */ +function validateAxisLabel(label, fieldName) { + if (label !== undefined && label !== null) { + if (typeof label !== 'string') { + throw new ValidationError(`${fieldName} must be a string.`); + } + if (label.length > 100) { + throw new ValidationError( + `${fieldName} must be 100 characters or less.` + ); + } + } +} + +/** + * Validate quadrant_index. + */ +function validateQuadrantIndex(quadrantIndex) { + if ( + quadrantIndex === undefined || + quadrantIndex === null || + !Number.isInteger(quadrantIndex) || + quadrantIndex < 0 || + quadrantIndex > 3 + ) { + throw new ValidationError( + 'Invalid quadrant_index. Must be an integer between 0 and 3.' + ); + } + return quadrantIndex; +} + +/** + * Validate position. + */ +function validatePosition(position) { + if (position !== undefined && position !== null) { + if (!Number.isInteger(position) || position < 0) { + throw new ValidationError( + 'Position must be a non-negative integer.' + ); + } + } + return position || 0; +} + +module.exports = { + validateName, + validateAxisLabel, + validateQuadrantIndex, + validatePosition, +}; diff --git a/backend/tests/helpers/setup.js b/backend/tests/helpers/setup.js index c721ff2a2..c63cb8d42 100644 --- a/backend/tests/helpers/setup.js +++ b/backend/tests/helpers/setup.js @@ -20,6 +20,8 @@ beforeEach(async () => { try { // Use raw SQL for faster cleanup const tableNames = [ + 'task_matrices', + 'matrices', 'users', 'areas', 'projects', diff --git a/backend/tests/integration/matrices.test.js b/backend/tests/integration/matrices.test.js new file mode 100644 index 000000000..fe417fd2f --- /dev/null +++ b/backend/tests/integration/matrices.test.js @@ -0,0 +1,801 @@ +'use strict'; + +const request = require('supertest'); +const app = require('../../app'); +const { Matrix, TaskMatrix, Task, Project, Tag, Area } = require('../../models'); +const { createTestUser } = require('../helpers/testUtils'); + +describe('Matrices Routes', () => { + let user, agent; + + beforeEach(async () => { + user = await createTestUser({ email: 'matrix@example.com' }); + agent = request.agent(app); + await agent + .post('/api/login') + .send({ email: 'matrix@example.com', password: 'password123' }); + }); + + // ---------------------------------------------------------------- + // POST /api/matrices + // ---------------------------------------------------------------- + describe('POST /api/matrices', () => { + it('should create a matrix with name only', async () => { + const res = await agent.post('/api/matrices').send({ + name: 'Eisenhower', + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.name).toBe('Eisenhower'); + expect(res.body.data.id).toBeDefined(); + expect(res.body.data.uid).toBeDefined(); + expect(res.body.data.user_id).toBe(user.id); + }); + + it('should create a matrix with axis labels', async () => { + const res = await agent.post('/api/matrices').send({ + name: 'Priority', + x_axis_label_left: 'Urgent', + x_axis_label_right: 'Not Urgent', + y_axis_label_top: 'Important', + y_axis_label_bottom: 'Not Important', + }); + + expect(res.status).toBe(201); + expect(res.body.data.x_axis_label_left).toBe('Urgent'); + expect(res.body.data.x_axis_label_right).toBe('Not Urgent'); + expect(res.body.data.y_axis_label_top).toBe('Important'); + expect(res.body.data.y_axis_label_bottom).toBe('Not Important'); + }); + + it('should create a matrix linked to a project', async () => { + const project = await Project.create({ + name: 'Test Project', + user_id: user.id, + }); + + const res = await agent.post('/api/matrices').send({ + name: 'Project Matrix', + project_id: project.id, + }); + + expect(res.status).toBe(201); + expect(res.body.data.project_id).toBe(project.id); + }); + + it('should reject if name is missing', async () => { + const res = await agent.post('/api/matrices').send({}); + + expect(res.status).toBe(400); + }); + + it('should reject if name is empty', async () => { + const res = await agent.post('/api/matrices').send({ name: '' }); + + expect(res.status).toBe(400); + }); + + it('should reject if project does not belong to user', async () => { + const otherUser = await createTestUser({ + email: 'other@example.com', + }); + const project = await Project.create({ + name: 'Other Project', + user_id: otherUser.id, + }); + + const res = await agent.post('/api/matrices').send({ + name: 'Stolen Matrix', + project_id: project.id, + }); + + expect(res.status).toBe(404); + }); + + it('should require authentication', async () => { + const res = await request(app) + .post('/api/matrices') + .send({ name: 'Anon Matrix' }); + + expect(res.status).toBe(401); + }); + }); + + // ---------------------------------------------------------------- + // GET /api/matrices + // ---------------------------------------------------------------- + describe('GET /api/matrices', () => { + beforeEach(async () => { + await Matrix.create({ + name: 'Matrix A', + user_id: user.id, + }); + await Matrix.create({ + name: 'Matrix B', + user_id: user.id, + }); + }); + + it('should list all matrices for the user', async () => { + const res = await agent.get('/api/matrices'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveLength(2); + }); + + it('should not include matrices from other users', async () => { + const otherUser = await createTestUser({ + email: 'other@example.com', + }); + await Matrix.create({ + name: 'Other Matrix', + user_id: otherUser.id, + }); + + const res = await agent.get('/api/matrices'); + + expect(res.body.data).toHaveLength(2); + }); + + it('should filter by project_id', async () => { + const project = await Project.create({ + name: 'P1', + user_id: user.id, + }); + await Matrix.create({ + name: 'Project Matrix', + user_id: user.id, + project_id: project.id, + }); + + const res = await agent.get( + `/api/matrices?project_id=${project.id}` + ); + + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].name).toBe('Project Matrix'); + }); + + it('should include taskCount', async () => { + const matrix = await Matrix.create({ + name: 'Counted', + user_id: user.id, + }); + const task = await Task.create({ + name: 'T1', + user_id: user.id, + }); + await TaskMatrix.create({ + task_id: task.id, + matrix_id: matrix.id, + quadrant_index: 0, + }); + + const res = await agent.get('/api/matrices'); + + const counted = res.body.data.find((m) => m.name === 'Counted'); + expect(counted.taskCount).toBe(1); + }); + + it('should require authentication', async () => { + const res = await request(app).get('/api/matrices'); + + expect(res.status).toBe(401); + }); + }); + + // ---------------------------------------------------------------- + // GET /api/matrices/:matrixId + // ---------------------------------------------------------------- + describe('GET /api/matrices/:matrixId', () => { + it('should return matrix with tasks grouped by quadrant', async () => { + const matrix = await Matrix.create({ + name: 'Detail', + user_id: user.id, + x_axis_label_left: 'Left', + x_axis_label_right: 'Right', + y_axis_label_top: 'Top', + y_axis_label_bottom: 'Bottom', + }); + const t1 = await Task.create({ + name: 'Task A', + user_id: user.id, + }); + const t2 = await Task.create({ + name: 'Task B', + user_id: user.id, + }); + await TaskMatrix.create({ + task_id: t1.id, + matrix_id: matrix.id, + quadrant_index: 0, + }); + await TaskMatrix.create({ + task_id: t2.id, + matrix_id: matrix.id, + quadrant_index: 2, + }); + + const res = await agent.get(`/api/matrices/${matrix.id}`); + + expect(res.status).toBe(200); + expect(res.body.data.name).toBe('Detail'); + expect(res.body.data.quadrants['0']).toHaveLength(1); + expect(res.body.data.quadrants['1']).toHaveLength(0); + expect(res.body.data.quadrants['2']).toHaveLength(1); + expect(res.body.data.quadrants['3']).toHaveLength(0); + expect(res.body.data.quadrants['0'][0].name).toBe('Task A'); + }); + + it('should include unassigned tasks when project-linked', async () => { + const project = await Project.create({ + name: 'P', + user_id: user.id, + }); + const matrix = await Matrix.create({ + name: 'ProjMatrix', + user_id: user.id, + project_id: project.id, + }); + const assigned = await Task.create({ + name: 'Assigned', + user_id: user.id, + project_id: project.id, + }); + const unassigned = await Task.create({ + name: 'Unassigned', + user_id: user.id, + project_id: project.id, + }); + await TaskMatrix.create({ + task_id: assigned.id, + matrix_id: matrix.id, + quadrant_index: 1, + }); + + const res = await agent.get(`/api/matrices/${matrix.id}`); + + expect(res.body.data.quadrants['1']).toHaveLength(1); + expect(res.body.data.unassigned).toHaveLength(1); + expect(res.body.data.unassigned[0].name).toBe('Unassigned'); + }); + + it('should return 404 for non-existent matrix', async () => { + const res = await agent.get('/api/matrices/99999'); + + expect(res.status).toBe(404); + }); + + it('should not allow access to another user\'s matrix', async () => { + const otherUser = await createTestUser({ + email: 'other@example.com', + }); + const matrix = await Matrix.create({ + name: 'Private', + user_id: otherUser.id, + }); + + const res = await agent.get(`/api/matrices/${matrix.id}`); + + expect(res.status).toBe(404); + }); + }); + + // ---------------------------------------------------------------- + // PUT /api/matrices/:matrixId + // ---------------------------------------------------------------- + describe('PUT /api/matrices/:matrixId', () => { + let matrix; + + beforeEach(async () => { + matrix = await Matrix.create({ + name: 'Original', + user_id: user.id, + x_axis_label_left: 'Left', + }); + }); + + it('should update matrix name', async () => { + const res = await agent + .put(`/api/matrices/${matrix.id}`) + .send({ name: 'Updated' }); + + expect(res.status).toBe(200); + expect(res.body.data.name).toBe('Updated'); + }); + + it('should update axis labels', async () => { + const res = await agent + .put(`/api/matrices/${matrix.id}`) + .send({ y_axis_label_top: 'High Impact' }); + + expect(res.status).toBe(200); + expect(res.body.data.y_axis_label_top).toBe('High Impact'); + }); + + it('should return 404 for non-existent matrix', async () => { + const res = await agent + .put('/api/matrices/99999') + .send({ name: 'Ghost' }); + + expect(res.status).toBe(404); + }); + + it('should reject empty name', async () => { + const res = await agent + .put(`/api/matrices/${matrix.id}`) + .send({ name: '' }); + + expect(res.status).toBe(400); + }); + }); + + // ---------------------------------------------------------------- + // DELETE /api/matrices/:matrixId + // ---------------------------------------------------------------- + describe('DELETE /api/matrices/:matrixId', () => { + it('should delete a matrix', async () => { + const matrix = await Matrix.create({ + name: 'Doomed', + user_id: user.id, + }); + + const res = await agent.delete(`/api/matrices/${matrix.id}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const found = await Matrix.findByPk(matrix.id); + expect(found).toBeNull(); + }); + + it('should return 404 for non-existent matrix', async () => { + const res = await agent.delete('/api/matrices/99999'); + + expect(res.status).toBe(404); + }); + + it('should not delete another user\'s matrix', async () => { + const otherUser = await createTestUser({ + email: 'other@example.com', + }); + const matrix = await Matrix.create({ + name: 'Safe', + user_id: otherUser.id, + }); + + const res = await agent.delete(`/api/matrices/${matrix.id}`); + + expect(res.status).toBe(404); + + const found = await Matrix.findByPk(matrix.id); + expect(found).not.toBeNull(); + }); + }); + + // ---------------------------------------------------------------- + // PUT /api/matrices/:matrixId/tasks/:taskId — Assign task + // ---------------------------------------------------------------- + describe('PUT /api/matrices/:matrixId/tasks/:taskId', () => { + let matrix, task; + + beforeEach(async () => { + matrix = await Matrix.create({ + name: 'Assign Test', + user_id: user.id, + }); + task = await Task.create({ + name: 'My Task', + user_id: user.id, + }); + }); + + it('should assign a task to a quadrant', async () => { + const res = await agent + .put(`/api/matrices/${matrix.id}/tasks/${task.id}`) + .send({ quadrant_index: 0 }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.quadrant_index).toBe(0); + expect(res.body.data.task_id).toBe(task.id); + expect(res.body.data.matrix_id).toBe(matrix.id); + }); + + it('should move a task to a different quadrant', async () => { + await TaskMatrix.create({ + task_id: task.id, + matrix_id: matrix.id, + quadrant_index: 0, + }); + + const res = await agent + .put(`/api/matrices/${matrix.id}/tasks/${task.id}`) + .send({ quadrant_index: 3 }); + + expect(res.status).toBe(200); + expect(res.body.data.quadrant_index).toBe(3); + }); + + it('should reject invalid quadrant_index', async () => { + const res = await agent + .put(`/api/matrices/${matrix.id}/tasks/${task.id}`) + .send({ quadrant_index: 5 }); + + expect(res.status).toBe(400); + }); + + it('should reject missing quadrant_index', async () => { + const res = await agent + .put(`/api/matrices/${matrix.id}/tasks/${task.id}`) + .send({}); + + expect(res.status).toBe(400); + }); + + it('should return 404 for non-existent matrix', async () => { + const res = await agent + .put(`/api/matrices/99999/tasks/${task.id}`) + .send({ quadrant_index: 0 }); + + expect(res.status).toBe(404); + }); + + it('should return 404 for non-existent task', async () => { + const res = await agent + .put(`/api/matrices/${matrix.id}/tasks/99999`) + .send({ quadrant_index: 0 }); + + expect(res.status).toBe(404); + }); + + it('should not assign another user\'s task', async () => { + const otherUser = await createTestUser({ + email: 'other@example.com', + }); + const otherTask = await Task.create({ + name: 'Not yours', + user_id: otherUser.id, + }); + + const res = await agent + .put(`/api/matrices/${matrix.id}/tasks/${otherTask.id}`) + .send({ quadrant_index: 1 }); + + expect(res.status).toBe(404); + }); + + it('should accept position parameter', async () => { + const res = await agent + .put(`/api/matrices/${matrix.id}/tasks/${task.id}`) + .send({ quadrant_index: 2, position: 5 }); + + expect(res.status).toBe(201); + expect(res.body.data.position).toBe(5); + }); + }); + + // ---------------------------------------------------------------- + // DELETE /api/matrices/:matrixId/tasks/:taskId — Remove task + // ---------------------------------------------------------------- + describe('DELETE /api/matrices/:matrixId/tasks/:taskId', () => { + it('should remove a task from a matrix', async () => { + const matrix = await Matrix.create({ + name: 'Remove Test', + user_id: user.id, + }); + const task = await Task.create({ + name: 'Removable', + user_id: user.id, + }); + await TaskMatrix.create({ + task_id: task.id, + matrix_id: matrix.id, + quadrant_index: 1, + }); + + const res = await agent.delete( + `/api/matrices/${matrix.id}/tasks/${task.id}` + ); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const found = await TaskMatrix.findOne({ + where: { task_id: task.id, matrix_id: matrix.id }, + }); + expect(found).toBeNull(); + }); + + it('should return 404 if task is not in matrix', async () => { + const matrix = await Matrix.create({ + name: 'M', + user_id: user.id, + }); + const task = await Task.create({ + name: 'T', + user_id: user.id, + }); + + const res = await agent.delete( + `/api/matrices/${matrix.id}/tasks/${task.id}` + ); + + expect(res.status).toBe(404); + }); + + it('should not affect the task itself', async () => { + const matrix = await Matrix.create({ + name: 'M', + user_id: user.id, + }); + const task = await Task.create({ + name: 'Survivor', + user_id: user.id, + }); + await TaskMatrix.create({ + task_id: task.id, + matrix_id: matrix.id, + quadrant_index: 0, + }); + + await agent.delete( + `/api/matrices/${matrix.id}/tasks/${task.id}` + ); + + const found = await Task.findByPk(task.id); + expect(found).not.toBeNull(); + expect(found.name).toBe('Survivor'); + }); + }); + + // ---------------------------------------------------------------- + // GET /api/tasks/:taskId/matrices — Task placements + // ---------------------------------------------------------------- + describe('GET /api/tasks/:taskId/matrices', () => { + it('should return all matrix placements for a task', async () => { + const task = await Task.create({ + name: 'Multi', + user_id: user.id, + }); + const m1 = await Matrix.create({ + name: 'M1', + user_id: user.id, + y_axis_label_top: 'Top', + }); + const m2 = await Matrix.create({ + name: 'M2', + user_id: user.id, + }); + await TaskMatrix.create({ + task_id: task.id, + matrix_id: m1.id, + quadrant_index: 0, + }); + await TaskMatrix.create({ + task_id: task.id, + matrix_id: m2.id, + quadrant_index: 3, + }); + + const res = await agent.get(`/api/tasks/${task.id}/matrices`); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0].matrix.name).toBeDefined(); + expect(res.body.data[0].quadrant_index).toBeDefined(); + }); + + it('should return empty array for task with no placements', async () => { + const task = await Task.create({ + name: 'Alone', + user_id: user.id, + }); + + const res = await agent.get(`/api/tasks/${task.id}/matrices`); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(0); + }); + }); + + // ---------------------------------------------------------------- + // GET /api/matrices/placements — Bulk placements + // ---------------------------------------------------------------- + describe('GET /api/matrices/placements', () => { + it('should return all placements for the user', async () => { + const matrix = await Matrix.create({ + name: 'Bulk', + user_id: user.id, + }); + const t1 = await Task.create({ + name: 'T1', + user_id: user.id, + }); + const t2 = await Task.create({ + name: 'T2', + user_id: user.id, + }); + await TaskMatrix.create({ + task_id: t1.id, + matrix_id: matrix.id, + quadrant_index: 0, + }); + await TaskMatrix.create({ + task_id: t2.id, + matrix_id: matrix.id, + quadrant_index: 1, + }); + + const res = await agent.get('/api/matrices/placements'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0]).toHaveProperty('task_id'); + expect(res.body.data[0]).toHaveProperty('matrix_id'); + expect(res.body.data[0]).toHaveProperty('quadrant_index'); + expect(res.body.data[0]).toHaveProperty('matrix_name'); + }); + + it('should not include other users\' placements', async () => { + const otherUser = await createTestUser({ + email: 'other@example.com', + }); + const otherMatrix = await Matrix.create({ + name: 'Private', + user_id: otherUser.id, + }); + const otherTask = await Task.create({ + name: 'Secret', + user_id: otherUser.id, + }); + await TaskMatrix.create({ + task_id: otherTask.id, + matrix_id: otherMatrix.id, + quadrant_index: 2, + }); + + const res = await agent.get('/api/matrices/placements'); + + expect(res.body.data).toHaveLength(0); + }); + }); + + // ---------------------------------------------------------------- + // GET /api/matrices/:matrixId/browse — Browse available tasks + // ---------------------------------------------------------------- + describe('GET /api/matrices/:matrixId/browse', () => { + let matrix; + + beforeEach(async () => { + matrix = await Matrix.create({ + name: 'Browse Test', + user_id: user.id, + }); + }); + + it('should return tasks filtered by project', async () => { + const project = await Project.create({ + name: 'Proj', + user_id: user.id, + }); + await Task.create({ + name: 'Project Task', + user_id: user.id, + project_id: project.id, + }); + await Task.create({ + name: 'Other Task', + user_id: user.id, + }); + + const res = await agent.get( + `/api/matrices/${matrix.id}/browse?source=project&sourceId=${project.id}` + ); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].name).toBe('Project Task'); + }); + + it('should exclude tasks already in the matrix', async () => { + const project = await Project.create({ + name: 'P', + user_id: user.id, + }); + const assigned = await Task.create({ + name: 'Already Placed', + user_id: user.id, + project_id: project.id, + }); + await Task.create({ + name: 'Available', + user_id: user.id, + project_id: project.id, + }); + await TaskMatrix.create({ + task_id: assigned.id, + matrix_id: matrix.id, + quadrant_index: 0, + }); + + const res = await agent.get( + `/api/matrices/${matrix.id}/browse?source=project&sourceId=${project.id}` + ); + + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].name).toBe('Available'); + }); + + it('should return tasks filtered by area', async () => { + const area = await Area.create({ + name: 'Work', + user_id: user.id, + }); + const project = await Project.create({ + name: 'Work Proj', + user_id: user.id, + area_id: area.id, + }); + await Task.create({ + name: 'Area Task', + user_id: user.id, + project_id: project.id, + }); + + const res = await agent.get( + `/api/matrices/${matrix.id}/browse?source=area&sourceId=${area.id}` + ); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].name).toBe('Area Task'); + }); + + it('should return tasks filtered by tag', async () => { + const tag = await Tag.create({ + name: 'urgent', + user_id: user.id, + }); + const task = await Task.create({ + name: 'Tagged Task', + user_id: user.id, + }); + await task.addTag(tag); + + const res = await agent.get( + `/api/matrices/${matrix.id}/browse?source=tag&sourceId=${tag.uid}` + ); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].name).toBe('Tagged Task'); + }); + + it('should reject missing source/sourceId', async () => { + const res = await agent.get( + `/api/matrices/${matrix.id}/browse` + ); + + expect(res.status).toBe(400); + }); + + it('should reject invalid source', async () => { + const res = await agent.get( + `/api/matrices/${matrix.id}/browse?source=invalid&sourceId=1` + ); + + expect(res.status).toBe(400); + }); + + it('should return 404 for non-existent matrix', async () => { + const res = await agent.get( + '/api/matrices/99999/browse?source=project&sourceId=1' + ); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/backend/tests/unit/models/matrix.test.js b/backend/tests/unit/models/matrix.test.js new file mode 100644 index 000000000..1b4ffbc37 --- /dev/null +++ b/backend/tests/unit/models/matrix.test.js @@ -0,0 +1,159 @@ +'use strict'; + +const { Matrix, TaskMatrix, Task, User } = require('../../../models'); + +describe('Matrix Model', () => { + let user; + + beforeEach(async () => { + user = await User.create({ + email: 'model@example.com', + password_digest: + '$2b$10$DPcA0XSvK9FT04mLyKGza.uHb8d.bESwP.XdQfQ47.sKVT4fYzbP.', + }); + }); + + it('should create a matrix with valid data', async () => { + const matrix = await Matrix.create({ + name: 'Test Matrix', + user_id: user.id, + }); + + expect(matrix.id).toBeDefined(); + expect(matrix.uid).toBeDefined(); + expect(matrix.name).toBe('Test Matrix'); + expect(matrix.user_id).toBe(user.id); + }); + + it('should set default axis labels', async () => { + const matrix = await Matrix.create({ + name: 'Defaults', + user_id: user.id, + }); + + expect(matrix.x_axis_label_left).toBe('Low Effort'); + expect(matrix.x_axis_label_right).toBe('High Effort'); + expect(matrix.y_axis_label_top).toBe('High Impact'); + expect(matrix.y_axis_label_bottom).toBe('Low Impact'); + }); + + it('should accept custom axis labels', async () => { + const matrix = await Matrix.create({ + name: 'Custom', + user_id: user.id, + x_axis_label_left: 'Urgent', + x_axis_label_right: 'Not Urgent', + y_axis_label_top: 'Important', + y_axis_label_bottom: 'Not Important', + }); + + expect(matrix.x_axis_label_left).toBe('Urgent'); + expect(matrix.y_axis_label_top).toBe('Important'); + }); + + it('should generate a unique uid', async () => { + const m1 = await Matrix.create({ name: 'A', user_id: user.id }); + const m2 = await Matrix.create({ name: 'B', user_id: user.id }); + + expect(m1.uid).toBeDefined(); + expect(m2.uid).toBeDefined(); + }); + + it('should reject a matrix without a name', async () => { + await expect( + Matrix.create({ user_id: user.id }) + ).rejects.toThrow(); + }); + + it('should reject a matrix with an empty name', async () => { + await expect( + Matrix.create({ name: '', user_id: user.id }) + ).rejects.toThrow(); + }); + + it('should allow null project_id', async () => { + const matrix = await Matrix.create({ + name: 'No Project', + user_id: user.id, + project_id: null, + }); + + expect(matrix.project_id).toBeNull(); + }); +}); + +describe('TaskMatrix Model', () => { + let user, matrix, task; + + beforeEach(async () => { + user = await User.create({ + email: 'tm@example.com', + password_digest: + '$2b$10$DPcA0XSvK9FT04mLyKGza.uHb8d.bESwP.XdQfQ47.sKVT4fYzbP.', + }); + matrix = await Matrix.create({ name: 'M', user_id: user.id }); + task = await Task.create({ name: 'T', user_id: user.id }); + }); + + it('should create a task-matrix association', async () => { + const tm = await TaskMatrix.create({ + task_id: task.id, + matrix_id: matrix.id, + quadrant_index: 2, + position: 1, + }); + + expect(tm.task_id).toBe(task.id); + expect(tm.matrix_id).toBe(matrix.id); + expect(tm.quadrant_index).toBe(2); + expect(tm.position).toBe(1); + }); + + it('should default quadrant_index to 0', async () => { + const tm = await TaskMatrix.create({ + task_id: task.id, + matrix_id: matrix.id, + }); + + expect(tm.quadrant_index).toBe(0); + }); + + it('should default position to 0', async () => { + const tm = await TaskMatrix.create({ + task_id: task.id, + matrix_id: matrix.id, + }); + + expect(tm.position).toBe(0); + }); + + it('should reject quadrant_index > 3', async () => { + await expect( + TaskMatrix.create({ + task_id: task.id, + matrix_id: matrix.id, + quadrant_index: 4, + }) + ).rejects.toThrow(); + }); + + it('should reject negative quadrant_index', async () => { + await expect( + TaskMatrix.create({ + task_id: task.id, + matrix_id: matrix.id, + quadrant_index: -1, + }) + ).rejects.toThrow(); + }); + + it('should reject negative position', async () => { + await expect( + TaskMatrix.create({ + task_id: task.id, + matrix_id: matrix.id, + position: -1, + }) + ).rejects.toThrow(); + }); +}); diff --git a/backend/tests/unit/services/matrices-validation.test.js b/backend/tests/unit/services/matrices-validation.test.js new file mode 100644 index 000000000..f7ee7d902 --- /dev/null +++ b/backend/tests/unit/services/matrices-validation.test.js @@ -0,0 +1,172 @@ +'use strict'; + +const { + validateName, + validateAxisLabel, + validateQuadrantIndex, + validatePosition, +} = require('../../../modules/matrices/validation'); +const { ValidationError } = require('../../../shared/errors'); + +describe('Matrix Validation', () => { + describe('validateName', () => { + it('should return trimmed name for valid input', () => { + expect(validateName(' My Matrix ')).toBe('My Matrix'); + }); + + it('should accept a single-character name', () => { + expect(validateName('A')).toBe('A'); + }); + + it('should accept a name at the 255-char limit', () => { + const longName = 'a'.repeat(255); + expect(validateName(longName)).toBe(longName); + }); + + it('should throw for empty string', () => { + expect(() => validateName('')).toThrow(ValidationError); + }); + + it('should throw for whitespace-only string', () => { + expect(() => validateName(' ')).toThrow(ValidationError); + }); + + it('should throw for null', () => { + expect(() => validateName(null)).toThrow(ValidationError); + }); + + it('should throw for undefined', () => { + expect(() => validateName(undefined)).toThrow(ValidationError); + }); + + it('should throw for non-string (number)', () => { + expect(() => validateName(123)).toThrow(ValidationError); + }); + + it('should throw for name exceeding 255 characters', () => { + expect(() => validateName('a'.repeat(256))).toThrow( + ValidationError + ); + }); + }); + + describe('validateAxisLabel', () => { + it('should accept a valid string label', () => { + expect(() => + validateAxisLabel('Urgent', 'x_axis_label_left') + ).not.toThrow(); + }); + + it('should accept an empty string', () => { + expect(() => + validateAxisLabel('', 'x_axis_label_left') + ).not.toThrow(); + }); + + it('should accept undefined (optional field)', () => { + expect(() => + validateAxisLabel(undefined, 'x_axis_label_left') + ).not.toThrow(); + }); + + it('should accept null (optional field)', () => { + expect(() => + validateAxisLabel(null, 'x_axis_label_left') + ).not.toThrow(); + }); + + it('should accept a label at the 100-char limit', () => { + expect(() => + validateAxisLabel('a'.repeat(100), 'x_axis_label_left') + ).not.toThrow(); + }); + + it('should throw for non-string (number)', () => { + expect(() => + validateAxisLabel(42, 'x_axis_label_left') + ).toThrow(ValidationError); + }); + + it('should throw for label exceeding 100 characters', () => { + expect(() => + validateAxisLabel('a'.repeat(101), 'x_axis_label_left') + ).toThrow(ValidationError); + }); + + it('should include field name in error message', () => { + expect(() => + validateAxisLabel(42, 'y_axis_label_top') + ).toThrow(/y_axis_label_top/); + }); + }); + + describe('validateQuadrantIndex', () => { + it('should accept 0', () => { + expect(validateQuadrantIndex(0)).toBe(0); + }); + + it('should accept 1', () => { + expect(validateQuadrantIndex(1)).toBe(1); + }); + + it('should accept 2', () => { + expect(validateQuadrantIndex(2)).toBe(2); + }); + + it('should accept 3', () => { + expect(validateQuadrantIndex(3)).toBe(3); + }); + + it('should throw for negative number', () => { + expect(() => validateQuadrantIndex(-1)).toThrow(ValidationError); + }); + + it('should throw for 4', () => { + expect(() => validateQuadrantIndex(4)).toThrow(ValidationError); + }); + + it('should throw for float', () => { + expect(() => validateQuadrantIndex(1.5)).toThrow(ValidationError); + }); + + it('should throw for string', () => { + expect(() => validateQuadrantIndex('0')).toThrow(ValidationError); + }); + + it('should throw for null', () => { + expect(() => validateQuadrantIndex(null)).toThrow(ValidationError); + }); + + it('should throw for undefined', () => { + expect(() => validateQuadrantIndex(undefined)).toThrow( + ValidationError + ); + }); + }); + + describe('validatePosition', () => { + it('should accept 0', () => { + expect(validatePosition(0)).toBe(0); + }); + + it('should accept a positive integer', () => { + expect(validatePosition(5)).toBe(5); + }); + + it('should default to 0 for undefined', () => { + expect(validatePosition(undefined)).toBe(0); + }); + + it('should default to 0 for null', () => { + expect(validatePosition(null)).toBe(0); + }); + + it('should throw for negative number', () => { + expect(() => validatePosition(-1)).toThrow(ValidationError); + }); + + it('should throw for float', () => { + expect(() => validatePosition(1.5)).toThrow(ValidationError); + }); + }); +}); diff --git a/frontend/App.tsx b/frontend/App.tsx index 8d201f4db..cc2866fef 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -26,6 +26,9 @@ import LoadingScreen from './components/Shared/LoadingScreen'; import InboxItems from './components/Inbox/InboxItems'; import Habits from './components/Habits/Habits'; import HabitDetails from './components/Habits/HabitDetails'; +import MatrixListPage from './components/Matrix/MatrixListPage'; +import MatrixDetailPage from './components/Matrix/MatrixDetailPage'; +import { MatrixPlacementsProvider } from './contexts/MatrixPlacementsContext'; import { setCurrentUser as setUserInStorage } from './utils/userUtils'; import { getApiPath, getLocalesPath } from './config/paths'; // Lazy load Tasks component to prevent issues with tags loading @@ -177,7 +180,9 @@ const App: React.FC = () => { isDarkMode={isDarkMode} toggleDarkMode={toggleDarkMode} > - + + + } > @@ -247,6 +252,14 @@ const App: React.FC = () => { path="/views/:uid" element={} /> + } + /> + } + /> } /> } /> { + describe('QUADRANT_STYLES', () => { + it('should define styles for quadrants 0-3', () => { + expect(QUADRANT_STYLES[0]).toBeDefined(); + expect(QUADRANT_STYLES[1]).toBeDefined(); + expect(QUADRANT_STYLES[2]).toBeDefined(); + expect(QUADRANT_STYLES[3]).toBeDefined(); + }); + + it('should not define styles for quadrant 4', () => { + expect(QUADRANT_STYLES[4]).toBeUndefined(); + }); + + it('should have all required properties on each style', () => { + const requiredKeys: (keyof QuadrantStyle)[] = [ + 'dot', + 'bg', + 'bgSubtle', + 'text', + 'ring', + ]; + + for (let i = 0; i < 4; i++) { + for (const key of requiredKeys) { + expect(QUADRANT_STYLES[i][key]).toBeDefined(); + expect(typeof QUADRANT_STYLES[i][key]).toBe('string'); + expect(QUADRANT_STYLES[i][key].length).toBeGreaterThan(0); + } + } + }); + + it('should use urgency-based colors: red → amber → blue → green', () => { + expect(QUADRANT_STYLES[0].dot).toContain('rose'); + expect(QUADRANT_STYLES[1].dot).toContain('amber'); + expect(QUADRANT_STYLES[2].dot).toContain('sky'); + expect(QUADRANT_STYLES[3].dot).toContain('emerald'); + }); + + it('should use unique colors for each quadrant', () => { + const dots = [0, 1, 2, 3].map((i) => QUADRANT_STYLES[i].dot); + const unique = new Set(dots); + expect(unique.size).toBe(4); + }); + }); + + describe('QUADRANT_STYLE_DEFAULT', () => { + it('should have all required properties', () => { + expect(QUADRANT_STYLE_DEFAULT.dot).toBeDefined(); + expect(QUADRANT_STYLE_DEFAULT.bg).toBeDefined(); + expect(QUADRANT_STYLE_DEFAULT.bgSubtle).toBeDefined(); + expect(QUADRANT_STYLE_DEFAULT.text).toBeDefined(); + expect(QUADRANT_STYLE_DEFAULT.ring).toBeDefined(); + }); + + it('should use gray as the fallback color', () => { + expect(QUADRANT_STYLE_DEFAULT.dot).toContain('gray'); + }); + }); + + describe('getQuadrantStyle', () => { + it('should return correct style for valid indices 0-3', () => { + expect(getQuadrantStyle(0)).toBe(QUADRANT_STYLES[0]); + expect(getQuadrantStyle(1)).toBe(QUADRANT_STYLES[1]); + expect(getQuadrantStyle(2)).toBe(QUADRANT_STYLES[2]); + expect(getQuadrantStyle(3)).toBe(QUADRANT_STYLES[3]); + }); + + it('should return fallback for out-of-range index', () => { + expect(getQuadrantStyle(4)).toBe(QUADRANT_STYLE_DEFAULT); + expect(getQuadrantStyle(-1)).toBe(QUADRANT_STYLE_DEFAULT); + expect(getQuadrantStyle(99)).toBe(QUADRANT_STYLE_DEFAULT); + }); + }); +}); diff --git a/frontend/__tests__/hooks/useMatrix.test.ts b/frontend/__tests__/hooks/useMatrix.test.ts new file mode 100644 index 000000000..ac547d375 --- /dev/null +++ b/frontend/__tests__/hooks/useMatrix.test.ts @@ -0,0 +1,158 @@ +import { snapshotMatrix, extractTaskFromQuadrants } from '../../hooks/useMatrix'; +import { MatrixDetail, MatrixTask } from '../../entities/Matrix'; + +/** Build a minimal MatrixTask for testing. */ +function mockTask(id: number, name = `Task ${id}`): MatrixTask { + return { + id, + name, + status: 0, + priority: null, + due_date: null, + project_id: null, + tags: [], + } as MatrixTask; +} + +/** Build a minimal MatrixDetail for testing. */ +function mockMatrix(tasks: Record = {}): MatrixDetail { + return { + id: 1, + uid: 'abc123', + name: 'Test Matrix', + user_id: 1, + project_id: null, + x_axis_label_left: 'Left', + x_axis_label_right: 'Right', + y_axis_label_top: 'Top', + y_axis_label_bottom: 'Bottom', + created_at: '2025-01-01', + updated_at: '2025-01-01', + quadrants: { + '0': tasks['0'] || [], + '1': tasks['1'] || [], + '2': tasks['2'] || [], + '3': tasks['3'] || [], + }, + unassigned: [], + }; +} + +describe('useMatrix helpers', () => { + describe('snapshotMatrix', () => { + it('should return a new object', () => { + const original = mockMatrix(); + const snapshot = snapshotMatrix(original); + + expect(snapshot).not.toBe(original); + }); + + it('should deep-clone quadrant arrays', () => { + const t1 = mockTask(1); + const original = mockMatrix({ '0': [t1] }); + const snapshot = snapshotMatrix(original); + + // Modifying snapshot's quadrant should NOT affect original + snapshot.quadrants['0'].push(mockTask(2)); + expect(original.quadrants['0']).toHaveLength(1); + expect(snapshot.quadrants['0']).toHaveLength(2); + }); + + it('should deep-clone unassigned array', () => { + const original = mockMatrix(); + original.unassigned = [mockTask(1)]; + const snapshot = snapshotMatrix(original); + + snapshot.unassigned.push(mockTask(2)); + expect(original.unassigned).toHaveLength(1); + expect(snapshot.unassigned).toHaveLength(2); + }); + + it('should preserve scalar properties', () => { + const original = mockMatrix(); + const snapshot = snapshotMatrix(original); + + expect(snapshot.id).toBe(original.id); + expect(snapshot.name).toBe(original.name); + expect(snapshot.x_axis_label_left).toBe(original.x_axis_label_left); + }); + }); + + describe('extractTaskFromQuadrants', () => { + it('should find and remove a task from its quadrant', () => { + const t1 = mockTask(1); + const t2 = mockTask(2); + const quadrants: Record = { + '0': [t1], + '1': [t2], + '2': [], + '3': [], + }; + + const result = extractTaskFromQuadrants(quadrants, 1); + + expect(result).not.toBeNull(); + expect(result!.id).toBe(1); + expect(quadrants['0']).toHaveLength(0); + expect(quadrants['1']).toHaveLength(1); + }); + + it('should return null if task is not found', () => { + const quadrants: Record = { + '0': [mockTask(1)], + '1': [], + '2': [], + '3': [], + }; + + const result = extractTaskFromQuadrants(quadrants, 999); + + expect(result).toBeNull(); + expect(quadrants['0']).toHaveLength(1); // unchanged + }); + + it('should return a shallow copy of the task', () => { + const original = mockTask(1, 'Original'); + const quadrants: Record = { + '0': [original], + '1': [], + '2': [], + '3': [], + }; + + const result = extractTaskFromQuadrants(quadrants, 1); + + expect(result).not.toBe(original); // different reference + expect(result!.name).toBe('Original'); + }); + + it('should mutate the quadrants object (remove from source)', () => { + const quadrants: Record = { + '0': [mockTask(1), mockTask(2), mockTask(3)], + '1': [], + '2': [], + '3': [], + }; + + extractTaskFromQuadrants(quadrants, 2); + + expect(quadrants['0']).toHaveLength(2); + expect(quadrants['0'].map((t) => t.id)).toEqual([1, 3]); + }); + + it('should work when task is in last quadrant', () => { + const quadrants: Record = { + '0': [], + '1': [], + '2': [], + '3': [mockTask(42)], + }; + + const result = extractTaskFromQuadrants(quadrants, 42); + + expect(result).not.toBeNull(); + expect(result!.id).toBe(42); + expect(quadrants['3']).toHaveLength(0); + }); + }); +}); diff --git a/frontend/__tests__/utils/matrixService.test.ts b/frontend/__tests__/utils/matrixService.test.ts new file mode 100644 index 000000000..d2d6ffc9e --- /dev/null +++ b/frontend/__tests__/utils/matrixService.test.ts @@ -0,0 +1,259 @@ +import { + fetchMatrices, + fetchMatrix, + createMatrix, + updateMatrix, + deleteMatrix, + assignTaskToMatrix, + removeTaskFromMatrix, + fetchTaskMatrices, + fetchAllPlacements, + browseMatrixTasks, +} from '../../utils/matrixService'; + +/* ------------------------------------------------------------------ */ +/* Mock global fetch */ +/* ------------------------------------------------------------------ */ +const mockFetch = jest.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +function jsonResponse(body: unknown, ok = true, status = ok ? 200 : 400) { + return Promise.resolve({ + ok, + status, + json: () => Promise.resolve(body), + } as Response); +} + +beforeEach(() => { + mockFetch.mockReset(); +}); + +/* ------------------------------------------------------------------ */ +/* fetchMatrices */ +/* ------------------------------------------------------------------ */ +describe('fetchMatrices', () => { + it('should call the matrices endpoint', async () => { + const payload = { success: true, data: [] }; + mockFetch.mockReturnValue(jsonResponse(payload)); + + const result = await fetchMatrices(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0][0]).toMatch(/matrices$/); + expect(result).toEqual(payload); + }); + + it('should include project_id query param when provided', async () => { + mockFetch.mockReturnValue(jsonResponse({ success: true, data: [] })); + + await fetchMatrices(5); + + expect(mockFetch.mock.calls[0][0]).toContain('project_id=5'); + }); + + it('should throw on non-ok response', async () => { + mockFetch.mockReturnValue(jsonResponse({}, false)); + + await expect(fetchMatrices()).rejects.toThrow('Failed to fetch matrices'); + }); +}); + +/* ------------------------------------------------------------------ */ +/* fetchMatrix */ +/* ------------------------------------------------------------------ */ +describe('fetchMatrix', () => { + it('should fetch a single matrix by id', async () => { + const payload = { success: true, data: { id: 1 } }; + mockFetch.mockReturnValue(jsonResponse(payload)); + + const result = await fetchMatrix(1); + + expect(mockFetch.mock.calls[0][0]).toMatch(/matrices\/1$/); + expect(result).toEqual(payload); + }); + + it('should throw on non-ok response', async () => { + mockFetch.mockReturnValue(jsonResponse({}, false)); + + await expect(fetchMatrix(1)).rejects.toThrow('Failed to load matrix'); + }); +}); + +/* ------------------------------------------------------------------ */ +/* createMatrix */ +/* ------------------------------------------------------------------ */ +describe('createMatrix', () => { + it('should POST to matrices endpoint', async () => { + const input = { name: 'Test Matrix' }; + const payload = { success: true, data: { id: 1, ...input } }; + mockFetch.mockReturnValue(jsonResponse(payload)); + + const result = await createMatrix(input); + + expect(mockFetch.mock.calls[0][1].method).toBe('POST'); + expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toEqual(input); + expect(result).toEqual(payload); + }); + + it('should throw server error message on failure', async () => { + mockFetch.mockReturnValue( + jsonResponse({ message: 'Name required' }, false) + ); + + await expect(createMatrix({})).rejects.toThrow('Name required'); + }); +}); + +/* ------------------------------------------------------------------ */ +/* updateMatrix */ +/* ------------------------------------------------------------------ */ +describe('updateMatrix', () => { + it('should PUT to matrices/:id', async () => { + const payload = { success: true, data: { id: 1, name: 'Updated' } }; + mockFetch.mockReturnValue(jsonResponse(payload)); + + const result = await updateMatrix(1, { name: 'Updated' }); + + expect(mockFetch.mock.calls[0][0]).toMatch(/matrices\/1$/); + expect(mockFetch.mock.calls[0][1].method).toBe('PUT'); + expect(result).toEqual(payload); + }); + + it('should throw server error message on failure', async () => { + mockFetch.mockReturnValue( + jsonResponse({ message: 'Not found' }, false) + ); + + await expect(updateMatrix(99, {})).rejects.toThrow('Not found'); + }); +}); + +/* ------------------------------------------------------------------ */ +/* deleteMatrix */ +/* ------------------------------------------------------------------ */ +describe('deleteMatrix', () => { + it('should DELETE matrices/:id', async () => { + mockFetch.mockReturnValue( + jsonResponse({ success: true, message: 'Deleted' }) + ); + + const result = await deleteMatrix(1); + + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE'); + expect(result.message).toBe('Deleted'); + }); + + it('should throw on failure', async () => { + mockFetch.mockReturnValue(jsonResponse({}, false)); + + await expect(deleteMatrix(1)).rejects.toThrow('Failed to delete matrix'); + }); +}); + +/* ------------------------------------------------------------------ */ +/* assignTaskToMatrix */ +/* ------------------------------------------------------------------ */ +describe('assignTaskToMatrix', () => { + it('should PUT to matrices/:matrixId/tasks/:taskId', async () => { + const payload = { success: true, data: {}, message: 'Assigned' }; + mockFetch.mockReturnValue(jsonResponse(payload)); + + await assignTaskToMatrix(1, 42, 2, 3); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body).toEqual({ quadrant_index: 2, position: 3 }); + expect(mockFetch.mock.calls[0][0]).toMatch(/matrices\/1\/tasks\/42$/); + }); + + it('should default position to 0', async () => { + mockFetch.mockReturnValue(jsonResponse({ success: true, data: {} })); + + await assignTaskToMatrix(1, 42, 1); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.position).toBe(0); + }); + + it('should throw server error message on failure', async () => { + mockFetch.mockReturnValue( + jsonResponse({ message: 'Invalid quadrant' }, false) + ); + + await expect(assignTaskToMatrix(1, 42, 9)).rejects.toThrow( + 'Invalid quadrant' + ); + }); +}); + +/* ------------------------------------------------------------------ */ +/* removeTaskFromMatrix */ +/* ------------------------------------------------------------------ */ +describe('removeTaskFromMatrix', () => { + it('should DELETE matrices/:matrixId/tasks/:taskId', async () => { + mockFetch.mockReturnValue( + jsonResponse({ success: true, message: 'Removed' }) + ); + + const result = await removeTaskFromMatrix(1, 42); + + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE'); + expect(result.message).toBe('Removed'); + }); +}); + +/* ------------------------------------------------------------------ */ +/* fetchTaskMatrices */ +/* ------------------------------------------------------------------ */ +describe('fetchTaskMatrices', () => { + it('should GET tasks/:taskId/matrices', async () => { + mockFetch.mockReturnValue( + jsonResponse({ success: true, data: [] }) + ); + + await fetchTaskMatrices(42); + + expect(mockFetch.mock.calls[0][0]).toMatch(/tasks\/42\/matrices$/); + }); +}); + +/* ------------------------------------------------------------------ */ +/* fetchAllPlacements */ +/* ------------------------------------------------------------------ */ +describe('fetchAllPlacements', () => { + it('should GET matrices/placements', async () => { + mockFetch.mockReturnValue( + jsonResponse({ success: true, data: [] }) + ); + + await fetchAllPlacements(); + + expect(mockFetch.mock.calls[0][0]).toMatch(/matrices\/placements$/); + }); +}); + +/* ------------------------------------------------------------------ */ +/* browseMatrixTasks */ +/* ------------------------------------------------------------------ */ +describe('browseMatrixTasks', () => { + it('should GET matrices/:id/browse with source and sourceId', async () => { + mockFetch.mockReturnValue( + jsonResponse({ success: true, data: [] }) + ); + + await browseMatrixTasks(1, 'project', 5); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toMatch(/matrices\/1\/browse/); + expect(url).toContain('source=project'); + expect(url).toContain('sourceId=5'); + }); + + it('should throw on failure', async () => { + mockFetch.mockReturnValue(jsonResponse({}, false)); + + await expect(browseMatrixTasks(1, 'area', 2)).rejects.toThrow( + 'Failed to browse tasks' + ); + }); +}); diff --git a/frontend/components/Matrix/DraggableTask.tsx b/frontend/components/Matrix/DraggableTask.tsx new file mode 100644 index 000000000..f56343714 --- /dev/null +++ b/frontend/components/Matrix/DraggableTask.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { useDraggable } from '@dnd-kit/core'; +import { CSS } from '@dnd-kit/utilities'; +import { MatrixTask } from '../../entities/Matrix'; +import { useTranslation } from 'react-i18next'; + +interface DraggableTaskProps { + task: MatrixTask; + onRemove?: (taskId: number) => void; +} + +const priorityColors: Record = { + 1: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + 2: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', +}; + +const priorityLabels: Record = { + 1: 'Medium', + 2: 'High', +}; + +const DraggableTask: React.FC = ({ task, onRemove }) => { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, transform, isDragging } = + useDraggable({ + id: `task-${task.id}`, + data: { taskId: task.id, taskName: task.name }, + }); + + const style: React.CSSProperties = { + transform: CSS.Translate.toString(transform), + opacity: isDragging ? 0.3 : 1, + }; + + const priority = + typeof task.priority === 'number' ? task.priority : undefined; + + return ( +
+
+

+ {task.name} +

+ {onRemove && ( + + )} +
+
+ {priority !== undefined && priority > 0 && ( + + {priorityLabels[priority]} + + )} + {task.due_date && ( + + {new Date(task.due_date).toLocaleDateString()} + + )} + {task.tags && + task.tags.slice(0, 2).map((tag) => ( + + {tag.name} + + ))} +
+
+ ); +}; + +export default DraggableTask; diff --git a/frontend/components/Matrix/MatrixBoard.tsx b/frontend/components/Matrix/MatrixBoard.tsx new file mode 100644 index 000000000..132da8273 --- /dev/null +++ b/frontend/components/Matrix/MatrixBoard.tsx @@ -0,0 +1,198 @@ +import React, { useState, useMemo } from 'react'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + closestCenter, + PointerSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { MatrixDetail, MatrixTask } from '../../entities/Matrix'; +import Quadrant from './Quadrant'; +import UnassignedSidebar from './UnassignedSidebar'; +import { useTranslation } from 'react-i18next'; +import { useMatrixStore } from '../../store/useMatrixStore'; + +interface MatrixBoardProps { + matrix: MatrixDetail; + moveTask: (taskId: number, newQuadrantIndex: number) => Promise; + removeTask: (taskId: number) => Promise; + onQuickAddTask?: (taskName: string, quadrantIndex: number) => Promise; + /** Incremented to re-trigger sidebar browse fetch */ + reloadTrigger?: number; +} + +const MatrixBoard: React.FC = ({ + matrix, + moveTask, + removeTask, + onQuickAddTask, + reloadTrigger, +}) => { + const { t } = useTranslation(); + const { setActiveDragTaskId } = useMatrixStore(); + const [activeDragTask, setActiveDragTask] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 5, + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 200, + tolerance: 5, + }, + }) + ); + + /** Pre-sorted tasks for each quadrant, memoized to avoid repeated sorts. */ + const sortedQuadrants = useMemo(() => { + const sorted = (index: number): MatrixTask[] => + [...(matrix.quadrants[String(index)] || [])].sort( + (a, b) => (a.TaskMatrix?.position || 0) - (b.TaskMatrix?.position || 0) + ); + return { 0: sorted(0), 1: sorted(1), 2: sorted(2), 3: sorted(3) }; + }, [matrix.quadrants]); + + /** All assigned tasks across quadrants, memoized for drag lookup. */ + const allAssignedTasks = useMemo( + () => [...sortedQuadrants[0], ...sortedQuadrants[1], ...sortedQuadrants[2], ...sortedQuadrants[3]], + [sortedQuadrants] + ); + + const handleDragStart = (event: DragStartEvent) => { + const taskId = event.active.data.current?.taskId; + const taskName = event.active.data.current?.taskName; + setActiveDragTaskId(taskId ?? null); + + // Find the task across all quadrants + unassigned for the overlay + if (taskId !== undefined) { + const found = + allAssignedTasks.find((t) => t.id === taskId) ?? + matrix.unassigned?.find((t) => t.id === taskId); + // Use the found task, or build a minimal one from drag data (sidebar browsed tasks) + setActiveDragTask(found ?? (taskName ? { id: taskId, name: taskName } as MatrixTask : null)); + } + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveDragTaskId(null); + setActiveDragTask(null); + const { active, over } = event; + if (!over) return; + + const taskId = active.data.current?.taskId; + const newQuadrantIndex = over.data.current?.quadrantIndex; + + if (taskId === undefined || newQuadrantIndex === undefined) return; + + // If dropped on unassigned sidebar, remove from matrix + if (newQuadrantIndex === -1) { + removeTask(taskId); + return; + } + + // If from unassigned or different quadrant, move it + const task = allAssignedTasks.find((t) => t.id === taskId); + if (!task || task.TaskMatrix?.quadrant_index !== newQuadrantIndex) { + moveTask(taskId, newQuadrantIndex); + } + }; + + return ( + +
+ {/* Main matrix area */} +
+ {/* Header */} +
+

+ {t( + 'matrix.actions.dragHint', + 'Drag and drop tasks to prioritize' + )} +

+
+ + {/* Matrix Container — px/py reserves space for rotated axis labels */} +
+ {/* Y-axis labels */} +
+ {matrix.y_axis_label_top} +
+
+ {matrix.y_axis_label_bottom} +
+ + {/* X-axis labels */} +
+ {matrix.x_axis_label_left} +
+
+ {matrix.x_axis_label_right} +
+ + {/* 2x2 Grid */} +
+ + + + +
+
+
+ + {/* Unassigned sidebar — now a category browser */} + +
+ + {activeDragTask ? ( +
+

+ {activeDragTask.name} +

+
+ ) : null} +
+
+ ); +}; + +export default MatrixBoard; diff --git a/frontend/components/Matrix/MatrixDetailPage.tsx b/frontend/components/Matrix/MatrixDetailPage.tsx new file mode 100644 index 000000000..a3db6b2c8 --- /dev/null +++ b/frontend/components/Matrix/MatrixDetailPage.tsx @@ -0,0 +1,180 @@ +import React, { useState, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + ArrowLeftIcon, + Cog6ToothIcon, + TrashIcon, +} from '@heroicons/react/24/outline'; +import { useMatrix } from '../../hooks/useMatrix'; +import MatrixBoard from './MatrixBoard'; +import MatrixModal from './MatrixModal'; +import { deleteMatrix, assignTaskToMatrix } from '../../utils/matrixService'; +import { createTask } from '../../utils/tasksService'; +import { useMatrixPlacements } from '../../contexts/MatrixPlacementsContext'; + +const MatrixDetailPage: React.FC = () => { + const { matrixId } = useParams<{ matrixId: string }>(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + const numericId = matrixId ? parseInt(matrixId, 10) : null; + const { + matrix, + isError, + reload, + moveTask, + removeTask, + updateMatrix, + } = useMatrix(numericId); + + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [reloadTrigger, setReloadTrigger] = useState(0); + const { reload: reloadPlacements } = useMatrixPlacements(); + + /** Wrap moveTask to also refresh the sidebar browse */ + const handleMoveTask = useCallback( + async (taskId: number, newQuadrantIndex: number) => { + await moveTask(taskId, newQuadrantIndex); + setReloadTrigger((c) => c + 1); + }, + [moveTask] + ); + + /** Wrap removeTask to also refresh the sidebar browse */ + const handleRemoveTask = useCallback( + async (taskId: number) => { + await removeTask(taskId); + setReloadTrigger((c) => c + 1); + }, + [removeTask] + ); + + const handleDelete = async () => { + if (!numericId) return; + setIsDeleting(true); + try { + await deleteMatrix(numericId); + navigate('/matrices'); + } catch { + setIsDeleting(false); + } + }; + + /** + * Quick-add: create a task and immediately assign it to the given quadrant. + */ + const handleQuickAddTask = useCallback( + async (taskName: string, quadrantIndex: number) => { + if (!numericId || !matrix) return; + const newTask = await createTask({ + name: taskName, + status: 0, + completed_at: null, + }); + if (newTask.id) { + await assignTaskToMatrix(numericId, newTask.id, quadrantIndex); + } + // Reload matrix data, sidebar browse, and placement cache + reload(); + setReloadTrigger((c) => c + 1); + reloadPlacements(); + }, + [numericId, matrix, reload, reloadPlacements] + ); + + if (!matrix) { + if (isError) { + return ( +
+
+ {t( + 'matrix.errors.notFound', + 'Matrix not found' + )} +
+ +
+ ); + } + return ( +
+
+ {t('common.loading', 'Loading...')} +
+
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ +
+

+ {matrix.name} +

+
+
+
+ + +
+
+ + {/* Matrix board */} +
+ +
+ + {/* Settings modal */} + setIsSettingsOpen(false)} + onSave={updateMatrix} + matrix={matrix} + /> +
+ ); +}; + +export default MatrixDetailPage; diff --git a/frontend/components/Matrix/MatrixListPage.tsx b/frontend/components/Matrix/MatrixListPage.tsx new file mode 100644 index 000000000..acff06a6d --- /dev/null +++ b/frontend/components/Matrix/MatrixListPage.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { Squares2X2Icon } from '@heroicons/react/24/solid'; +import { useMatrices } from '../../hooks/useMatrix'; +import { Matrix } from '../../entities/Matrix'; +import MatrixModal from './MatrixModal'; + + +const MatrixListPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const { matrices, isLoading, isError, createMatrix, deleteMatrix } = + useMatrices(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + // Auto-open create modal when navigated from project page + useEffect(() => { + const state = location.state as { createForProject?: number } | null; + if (state?.createForProject && !isLoading) { + setIsModalOpen(true); + // Clear the state to prevent re-triggering + navigate(location.pathname, { replace: true, state: {} }); + } + }, [location.state, isLoading, navigate, location.pathname]); + + const handleCreate = async (data: Partial) => { + const created = await createMatrix(data); + if (created?.id) { + navigate(`/matrices/${created.id}`); + } + }; + + const handleDelete = async (matrixId: number) => { + await deleteMatrix(matrixId); + setDeleteConfirm(null); + }; + + if (isLoading) { + return ( +
+
+ {t('common.loading', 'Loading...')} +
+
+ ); + } + + if (isError) { + return ( +
+
+ {t('matrix.errors.loadFailed', 'Failed to load matrix')} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ {t('matrix.title', 'Matrices')} +

+

+ {t( + 'matrix.pageDescription', + 'Prioritize tasks visually with custom 2×2 grids' + )} +

+
+ +
+ + {/* Matrix list */} + {matrices.length === 0 ? ( +
+ +

+ {t('matrix.empty.title', 'No matrices yet')} +

+

+ {t( + 'matrix.empty.description', + 'Create your first 2×2 matrix to start prioritizing tasks visually.' + )} +

+ +
+ ) : ( +
+ {matrices.map((matrix) => ( +
+ navigate(`/matrices/${matrix.id}`) + } + > +
+
+

+ {matrix.name} +

+ {matrix.project && ( +

+ {matrix.project.name} +

+ )} +
+ +
+ + {/* Mini axis preview */} +
+
+ {matrix.y_axis_label_top} /{' '} + {matrix.x_axis_label_left} +
+
+ {matrix.y_axis_label_top} /{' '} + {matrix.x_axis_label_right} +
+
+ {matrix.y_axis_label_bottom} /{' '} + {matrix.x_axis_label_left} +
+
+ {matrix.y_axis_label_bottom} /{' '} + {matrix.x_axis_label_right} +
+
+ +
+ {t('matrix.card.taskCount', { + count: matrix.taskCount || 0, + defaultValue: + '{{count}} task', + })} +
+ + {/* Delete confirmation */} + {deleteConfirm === matrix.id && ( +
e.stopPropagation()} + > +

+ {t( + 'matrix.delete.confirm', + 'Delete this matrix?' + )} +

+

+ {t( + 'matrix.delete.description', + 'Tasks will not be deleted. They will only be removed from this matrix.' + )} +

+
+ + +
+
+ )} +
+ ))} +
+ )} + + { + setIsModalOpen(false); + }} + onSave={handleCreate} + /> +
+ ); +}; + +export default MatrixListPage; diff --git a/frontend/components/Matrix/MatrixModal.tsx b/frontend/components/Matrix/MatrixModal.tsx new file mode 100644 index 000000000..d5765e4d8 --- /dev/null +++ b/frontend/components/Matrix/MatrixModal.tsx @@ -0,0 +1,275 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Matrix } from '../../entities/Matrix'; +import { getQuadrantStyle } from '../../constants/matrixColors'; + +interface MatrixPreset { + key: string; + name: string; + xAxisLeft: string; + xAxisRight: string; + yAxisTop: string; + yAxisBottom: string; +} + +const MATRIX_PRESETS: MatrixPreset[] = [ + { + key: 'eisenhower', + name: 'Eisenhower', + xAxisLeft: 'Urgent', + xAxisRight: 'Not Urgent', + yAxisTop: 'Important', + yAxisBottom: 'Not Important', + }, + { + key: 'effortImpact', + name: 'Effort / Impact', + xAxisLeft: 'Low Effort', + xAxisRight: 'High Effort', + yAxisTop: 'High Impact', + yAxisBottom: 'Low Impact', + }, + { + key: 'riskReward', + name: 'Risk / Reward', + xAxisLeft: 'Low Risk', + xAxisRight: 'High Risk', + yAxisTop: 'High Reward', + yAxisBottom: 'Low Reward', + }, +]; + +interface MatrixModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (data: Partial) => Promise; + matrix?: Matrix | null; +} + +const MatrixModal: React.FC = ({ + isOpen, + onClose, + onSave, + matrix, +}) => { + const { t } = useTranslation(); + + const [name, setName] = useState(''); + const [xAxisLeft, setXAxisLeft] = useState(''); + const [xAxisRight, setXAxisRight] = useState(''); + const [yAxisTop, setYAxisTop] = useState(''); + const [yAxisBottom, setYAxisBottom] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(''); + + const applyPreset = (preset: MatrixPreset) => { + setName(t(`matrix.preset.${preset.key}`, preset.name)); + setXAxisLeft(preset.xAxisLeft); + setXAxisRight(preset.xAxisRight); + setYAxisTop(preset.yAxisTop); + setYAxisBottom(preset.yAxisBottom); + }; + + useEffect(() => { + if (matrix) { + setName(matrix.name); + setXAxisLeft(matrix.x_axis_label_left); + setXAxisRight(matrix.x_axis_label_right); + setYAxisTop(matrix.y_axis_label_top); + setYAxisBottom(matrix.y_axis_label_bottom); + } else { + setName(''); + setXAxisLeft( + t('matrix.defaultXAxisLeft', 'Low Effort') + ); + setXAxisRight( + t('matrix.defaultXAxisRight', 'High Effort') + ); + setYAxisTop( + t('matrix.defaultYAxisTop', 'High Impact') + ); + setYAxisBottom( + t('matrix.defaultYAxisBottom', 'Low Impact') + ); + } + setError(''); + }, [matrix, isOpen, t]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + setError( + t( + 'matrix.errors.nameRequired', + 'Matrix name is required' + ) + ); + return; + } + + setIsSaving(true); + setError(''); + try { + await onSave({ + name: name.trim(), + x_axis_label_left: xAxisLeft, + x_axis_label_right: xAxisRight, + y_axis_label_top: yAxisTop, + y_axis_label_bottom: yAxisBottom, + }); + onClose(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : t('matrix.errors.saveFailed', 'Failed to save matrix') + ); + } finally { + setIsSaving(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+
+

+ {matrix + ? t('matrix.edit', 'Edit Matrix') + : t('matrix.create', 'Create Matrix')} +

+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder={t( + 'matrix.namePlaceholder', + 'e.g., Eisenhower Matrix' + )} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-400" + autoFocus + /> +
+ + {/* Presets — only show for new matrices */} + {!matrix && ( +
+ +
+ {MATRIX_PRESETS.map((preset) => ( + + ))} +
+
+ )} + + {/* Axis Labels — arranged to mirror the matrix layout */} +
+

+ {t('matrix.axisLabels', 'Axis Labels')} +

+ + {/* Top label (Y-axis top) — centered */} +
+ setYAxisTop(e.target.value)} + placeholder={t('matrix.yAxisTopPlaceholder', 'e.g., Important')} + className="w-48 px-3 py-1.5 text-sm text-center border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+ + {/* Middle row: Left label — mini grid — Right label */} +
+ setXAxisLeft(e.target.value)} + placeholder={t('matrix.xAxisLeftPlaceholder', 'e.g., Urgent')} + className="flex-1 px-3 py-1.5 text-sm text-center border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + + {/* Mini 2×2 visual — Q0 top-left (do first) to Q3 bottom-right (eliminate) */} +
+ {[0, 1, 2, 3].map((qi) => ( +
+ ))} +
+ + setXAxisRight(e.target.value)} + placeholder={t('matrix.xAxisRightPlaceholder', 'e.g., Not Urgent')} + className="flex-1 px-3 py-1.5 text-sm text-center border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+ + {/* Bottom label (Y-axis bottom) — centered */} +
+ setYAxisBottom(e.target.value)} + placeholder={t('matrix.yAxisBottomPlaceholder', 'e.g., Not Important')} + className="w-48 px-3 py-1.5 text-sm text-center border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+
+ + {/* Actions */} +
+ + +
+ +
+
+
+ ); +}; + +export default MatrixModal; diff --git a/frontend/components/Matrix/Quadrant.tsx b/frontend/components/Matrix/Quadrant.tsx new file mode 100644 index 000000000..fe77107f1 --- /dev/null +++ b/frontend/components/Matrix/Quadrant.tsx @@ -0,0 +1,138 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useDroppable } from '@dnd-kit/core'; +import { MatrixTask } from '../../entities/Matrix'; +import DraggableTask from './DraggableTask'; +import { useTranslation } from 'react-i18next'; + +interface QuadrantProps { + index: number; + tasks: MatrixTask[]; + className?: string; + onRemoveTask?: (taskId: number) => void; + onQuickAdd?: (taskName: string, quadrantIndex: number) => Promise; +} + +const Quadrant: React.FC = ({ + index, + tasks, + className = '', + onRemoveTask, + onQuickAdd, +}) => { + const { t } = useTranslation(); + const { isOver, setNodeRef } = useDroppable({ + id: `quadrant-${index}`, + data: { quadrantIndex: index }, + }); + + const [isAdding, setIsAdding] = useState(false); + const [newTaskName, setNewTaskName] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (isAdding && inputRef.current) { + inputRef.current.focus(); + } + }, [isAdding]); + + const handleQuickAdd = async () => { + const name = newTaskName.trim(); + if (!name || !onQuickAdd) return; + setIsSaving(true); + try { + await onQuickAdd(name, index); + setNewTaskName(''); + setIsAdding(false); + } catch { + // Keep the input open on error + } finally { + setIsSaving(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleQuickAdd(); + } else if (e.key === 'Escape') { + setIsAdding(false); + setNewTaskName(''); + } + }; + + return ( +
+ {tasks.length === 0 && !isAdding ? ( +
+ {t('matrix.quadrant.empty', 'Drop tasks here')} + {onQuickAdd && ( + + )} +
+ ) : ( + <> + {tasks.map((task) => ( + + ))} + {isAdding ? ( +
+ setNewTaskName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!newTaskName.trim()) { + setIsAdding(false); + } + }} + placeholder={t('matrix.quadrant.taskNamePlaceholder', 'Task name...')} + disabled={isSaving} + className="flex-1 min-w-0 px-2.5 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-400 disabled:opacity-50" + /> + +
+ ) : ( + onQuickAdd && ( + + ) + )} + + )} +
+ ); +}; + +export default Quadrant; diff --git a/frontend/components/Matrix/UnassignedSidebar.tsx b/frontend/components/Matrix/UnassignedSidebar.tsx new file mode 100644 index 000000000..7c07d05e1 --- /dev/null +++ b/frontend/components/Matrix/UnassignedSidebar.tsx @@ -0,0 +1,255 @@ +import React, { useMemo, useEffect, useState, useCallback } from 'react'; +import { useDroppable } from '@dnd-kit/core'; +import { MatrixTask } from '../../entities/Matrix'; +import DraggableTask from './DraggableTask'; +import { useTranslation } from 'react-i18next'; +import { useMatrixStore, SidebarCategory } from '../../store/useMatrixStore'; +import { useStore } from '../../store/useStore'; +import { browseMatrixTasks, BrowseSource } from '../../utils/matrixService'; +import { + FolderIcon, + RectangleGroupIcon, + TagIcon, + MagnifyingGlassIcon, +} from '@heroicons/react/24/outline'; + +interface UnassignedSidebarProps { + /** Tasks already unassigned (from matrix detail) - used as drop zone fallback */ + tasks: MatrixTask[]; + /** Matrix ID for the browse API */ + matrixId: number; + /** Reload counter — incremented when matrix data changes to re-fetch browse */ + reloadTrigger?: number; +} + +const CATEGORIES: { key: SidebarCategory; icon: React.ElementType; labelKey: string; fallback: string }[] = [ + { key: 'project', icon: FolderIcon, labelKey: 'matrix.sidebar.categoryProjects', fallback: 'Projects' }, + { key: 'area', icon: RectangleGroupIcon, labelKey: 'matrix.sidebar.categoryAreas', fallback: 'Areas' }, + { key: 'tag', icon: TagIcon, labelKey: 'matrix.sidebar.categoryTags', fallback: 'Tags' }, +]; + +const UnassignedSidebar: React.FC = ({ tasks, matrixId, reloadTrigger }) => { + const { t } = useTranslation(); + const { + sidebarSearchQuery, + setSidebarSearchQuery, + sidebarCategory, + setSidebarCategory, + sidebarSourceId, + setSidebarSourceId, + } = useMatrixStore(); + + // Use specific selectors to avoid re-renders from unrelated store changes + const projects = useStore((s) => s.projectsStore.projects); + const areas = useStore((s) => s.areasStore.areas); + const tags = useStore((s) => s.tagsStore.tags); + const areasHasLoaded = useStore((s) => s.areasStore.hasLoaded); + const tagsHasLoaded = useStore((s) => s.tagsStore.hasLoaded); + const loadAreas = useStore((s) => s.areasStore.loadAreas); + const loadTags = useStore((s) => s.tagsStore.loadTags); + + // Load areas and tags only if they haven't been loaded yet + useEffect(() => { + if (!areasHasLoaded) loadAreas(); + if (!tagsHasLoaded) loadTags(); + }, [areasHasLoaded, tagsHasLoaded, loadAreas, loadTags]); + + // Browse results from the API + const [browsedTasks, setBrowsedTasks] = useState([]); + const [isLoadingBrowse, setIsLoadingBrowse] = useState(false); + + // Fetch tasks when category + sourceId change + useEffect(() => { + if (!sidebarCategory || !sidebarSourceId) { + setBrowsedTasks([]); + return; + } + + let cancelled = false; + setIsLoadingBrowse(true); + + browseMatrixTasks(matrixId, sidebarCategory as BrowseSource, sidebarSourceId) + .then((result) => { + if (!cancelled) { + setBrowsedTasks(result.data); + } + }) + .catch(() => { + if (!cancelled) { + setBrowsedTasks([]); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoadingBrowse(false); + } + }); + + return () => { cancelled = true; }; + }, [matrixId, sidebarCategory, sidebarSourceId, reloadTrigger]); + + // Get items for the secondary dropdown based on selected category + const sourceItems = useMemo(() => { + switch (sidebarCategory) { + case 'project': + return (projects || []) + .filter((p) => p.status !== 'done' && p.status !== 'cancelled') + .map((p) => ({ id: String(p.id!), name: p.name })); + case 'area': + return (areas || []) + .filter((a) => a.active !== false) + .map((a) => ({ id: String(a.id!), name: a.name })); + case 'tag': + return (tags || []).map((tag) => ({ id: tag.uid!, name: tag.name })); + default: + return []; + } + }, [sidebarCategory, projects, areas, tags]); + + // Filter browsed tasks by search query + const filteredTasks = useMemo(() => { + const source = browsedTasks; + if (!sidebarSearchQuery.trim()) return source; + const q = sidebarSearchQuery.toLowerCase(); + return source.filter( + (task) => + task.name.toLowerCase().includes(q) || + task.tags?.some((tag) => tag.name.toLowerCase().includes(q)) + ); + }, [browsedTasks, sidebarSearchQuery]); + + // Make sidebar a droppable zone for removing tasks from quadrants + const { isOver, setNodeRef } = useDroppable({ + id: 'unassigned-sidebar', + data: { quadrantIndex: -1 }, + }); + + const handleCategoryClick = useCallback((cat: SidebarCategory) => { + if (sidebarCategory === cat) { + // Toggle off + setSidebarCategory(null); + } else { + setSidebarCategory(cat); + } + }, [sidebarCategory, setSidebarCategory]); + + const placeholderForCategory = useMemo(() => { + switch (sidebarCategory) { + case 'project': return t('matrix.sidebar.selectProject', 'Select project...'); + case 'area': return t('matrix.sidebar.selectArea', 'Select area...'); + case 'tag': return t('matrix.sidebar.selectTag', 'Select tag...'); + default: return ''; + } + }, [sidebarCategory, t]); + + const hasSelection = sidebarCategory && sidebarSourceId; + + return ( +
+ {/* Header */} +
+

+ {t('matrix.sidebar.browseTitle', 'Browse Tasks')} +

+ + {/* Category tabs */} +
+ {CATEGORIES.map(({ key, icon: Icon, labelKey, fallback }) => ( + + ))} +
+ + {/* Secondary dropdown — select specific item */} + {sidebarCategory && ( + + )} + + {/* Search within results */} + {hasSelection && ( +
+ + setSidebarSearchQuery(e.target.value)} + placeholder={t('matrix.sidebar.search', 'Search tasks...')} + className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-700 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+ )} +
+ + {/* Task list */} +
+ {!sidebarCategory ? ( + // No category selected +
+ +

+ {t('matrix.sidebar.selectCategory', 'Select a category above to browse tasks')} +

+
+ ) : !sidebarSourceId ? ( + // Category selected but no specific item +
+

+ {placeholderForCategory} +

+
+ ) : isLoadingBrowse ? ( +
+

+ {t('common.loading', 'Loading...')} +

+
+ ) : filteredTasks.length === 0 ? ( +

+ {browsedTasks.length === 0 + ? t('matrix.sidebar.empty', 'All tasks have been placed') + : t('search.noResults', 'No results found')} +

+ ) : ( + <> +

+ {filteredTasks.length} {t('matrix.sidebar.taskCount', 'tasks')} +

+ {filteredTasks.map((task) => ( + + ))} + + )} +
+
+ ); +}; + +export default UnassignedSidebar; diff --git a/frontend/components/Project/ProjectDetails.tsx b/frontend/components/Project/ProjectDetails.tsx index 187853e7f..08c979ecb 100644 --- a/frontend/components/Project/ProjectDetails.tsx +++ b/frontend/components/Project/ProjectDetails.tsx @@ -11,6 +11,7 @@ import { XCircleIcon, ChartBarIcon, CheckIcon, + Squares2X2Icon, } from '@heroicons/react/24/outline'; import { useToast } from '../Shared/ToastContext'; import ProjectModal from './ProjectModal'; @@ -43,6 +44,7 @@ import BannerEditModal from './BannerEditModal'; import ProjectTasksSection from './ProjectTasksSection'; import ProjectNotesSection from './ProjectNotesSection'; import { useProjectMetrics } from './useProjectMetrics'; +import { useMatrices } from '../../hooks/useMatrix'; const ProjectDetails: React.FC = () => { const UI_OPTIONS_KEY = 'ui_app_options'; @@ -78,6 +80,7 @@ const ProjectDetails: React.FC = () => { const [orderBy, setOrderBy] = useState('status:inProgressFirst'); const [taskSearchQuery, setTaskSearchQuery] = useState(''); const [isSearchExpanded, setIsSearchExpanded] = useState(false); + const { matrices: projectMatrices } = useMatrices(project?.id); const { isOpen: isModalOpen, openModal, @@ -886,6 +889,39 @@ const ProjectDetails: React.FC = () => { {activeTab === 'tasks' && (
+ + + + ); +}; + +export default SidebarMatrices; diff --git a/frontend/components/Sidebar/SidebarNav.tsx b/frontend/components/Sidebar/SidebarNav.tsx index 55dc86349..cae39d285 100644 --- a/frontend/components/Sidebar/SidebarNav.tsx +++ b/frontend/components/Sidebar/SidebarNav.tsx @@ -85,7 +85,7 @@ const SidebarNav: React.FC = ({ const isActive = (path: string, query?: string) => { if (path === '/inbox' || path === '/today' || path === '/calendar') { - const isPathMatch = location.pathname === path; + const isPathMatch = location.pathname === path || location.pathname.startsWith(path + '/'); return isPathMatch ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'; diff --git a/frontend/components/Task/TaskDetails.tsx b/frontend/components/Task/TaskDetails.tsx index 0f15925d3..9b011f684 100644 --- a/frontend/components/Task/TaskDetails.tsx +++ b/frontend/components/Task/TaskDetails.tsx @@ -30,6 +30,7 @@ import { TaskDueDateCard, TaskDeferUntilCard, TaskAttachmentsCard, + TaskMatrixCard, } from './TaskDetails/'; import { isTaskOverdueInTodayPlan, @@ -1165,6 +1166,8 @@ const TaskDetails: React.FC = () => { onSave={handleSaveDeferUntil} onCancel={handleCancelDeferUntilEdit} /> + +
)} diff --git a/frontend/components/Task/TaskDetails/TaskMatrixCard.tsx b/frontend/components/Task/TaskDetails/TaskMatrixCard.tsx new file mode 100644 index 000000000..6f3f23f66 --- /dev/null +++ b/frontend/components/Task/TaskDetails/TaskMatrixCard.tsx @@ -0,0 +1,272 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + Squares2X2Icon, + ArrowRightIcon, + ChevronLeftIcon, +} from '@heroicons/react/24/outline'; +import { + fetchTaskMatrices, + fetchMatrices, + assignTaskToMatrix, +} from '../../../utils/matrixService'; +import { Matrix, TaskMatrixPlacement } from '../../../entities/Matrix'; +import { getQuadrantStyle } from '../../../constants/matrixColors'; + +interface TaskMatrixCardProps { + taskId?: number; +} + +type AddStep = 'idle' | 'pickMatrix' | 'pickQuadrant'; + +/** Axis-intersection label, e.g. "Important · Urgent". */ +function quadrantLabel( + m: Pick, + qi: number +): string { + const y = qi < 2 ? m.y_axis_label_top : m.y_axis_label_bottom; + const x = qi % 2 === 0 ? m.x_axis_label_left : m.x_axis_label_right; + return `${y} · ${x}`; +} + +/** Shared tiny axis-label text style. */ +const AXIS_CLS = 'text-[9px] text-gray-400 dark:text-gray-500 truncate'; + +/* ------------------------------------------------------------------ */ +/* Axis-labelled 2×2 grid (shared between read-only + picker) */ +/* ------------------------------------------------------------------ */ +const MiniGrid: React.FC<{ + matrix: Pick; + activeQi?: number; + onClickCell?: (qi: number) => void; + disabled?: boolean; +}> = ({ matrix, activeQi, onClickCell, disabled }) => ( +
+
+ {matrix.x_axis_label_left} + {matrix.x_axis_label_right} +
+
+
+ + {matrix.y_axis_label_top} + + + {matrix.y_axis_label_bottom} + +
+
+ {[0, 1, 2, 3].map((qi) => { + const style = getQuadrantStyle(qi); + const isActive = qi === activeQi; + const isClickable = !!onClickCell; + + const cls = isActive + ? `${style.bg} ring-2 ring-offset-1 ring-gray-400 dark:ring-gray-300 dark:ring-offset-gray-900 shadow-sm` + : isClickable + ? `${style.bgSubtle} border border-gray-200 dark:border-gray-600 hover:ring-2 hover:ring-offset-1 hover:ring-gray-400` + : 'bg-gray-100 dark:bg-gray-700/50'; + + const Tag = isClickable ? 'button' : 'div'; + return ( + onClickCell!(qi) : undefined} + className={`h-[34px] rounded-sm transition-all ${cls} ${isClickable ? 'cursor-pointer' : ''}`} + title={isClickable ? quadrantLabel(matrix, qi) : undefined} + > + {isActive && ( +
+
+
+ )} + + ); + })} +
+
+
+); + +/* ------------------------------------------------------------------ */ +/* TaskMatrixCard */ +/* ------------------------------------------------------------------ */ +const TaskMatrixCard: React.FC = ({ taskId }) => { + const { t } = useTranslation(); + const [placements, setPlacements] = useState([]); + const [allMatrices, setAllMatrices] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [addStep, setAddStep] = useState('idle'); + const [selectedMatrix, setSelectedMatrix] = useState(null); + + /* ---- data loading ---- */ + const loadPlacements = useCallback(async () => { + if (!taskId) return; + try { + const r = await fetchTaskMatrices(taskId); + setPlacements(r.data || []); + } catch { /* silent */ } + }, [taskId]); + + useEffect(() => { + if (!taskId) { setLoading(false); return; } + let cancelled = false; + loadPlacements().finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [taskId, loadPlacements]); + + /* ---- add flow handlers ---- */ + const startAdd = useCallback(async () => { + setAddStep('pickMatrix'); + try { + const r = await fetchMatrices(); + setAllMatrices(r.data || []); + } catch { /* silent */ } + }, []); + + const pickMatrix = useCallback((m: Matrix) => { + setSelectedMatrix(m); + setAddStep('pickQuadrant'); + }, []); + + const pickQuadrant = useCallback(async (qi: number) => { + if (!taskId || !selectedMatrix?.id) return; + setSaving(true); + try { + await assignTaskToMatrix(selectedMatrix.id, taskId, qi); + await loadPlacements(); + setAddStep('idle'); + setSelectedMatrix(null); + } catch { /* silent */ } + finally { setSaving(false); } + }, [taskId, selectedMatrix, loadPlacements]); + + const cancel = useCallback(() => { + setAddStep('idle'); + setSelectedMatrix(null); + }, []); + + if (loading) return null; + + const existingIds = new Set(placements.map((p) => p.matrix.id!)); + + /* ---- render ---- */ + return ( +
+ + {/* --- Step 1: pick a matrix --- */} + {addStep === 'pickMatrix' && (() => { + const available = allMatrices.filter((m) => !existingIds.has(m.id!)); + return ( +
+

+ {t('matrix.actions.selectMatrix', 'Select a matrix')} +

+ {available.length === 0 ? ( +

+ {t('matrix.actions.noMatricesAvailable', 'No more matrices available')} +

+ ) : ( +
+ {available.map((m) => ( + + ))} +
+ )} +
+ +
+
+ ); + })()} + + {/* --- Step 2: pick a quadrant --- */} + {addStep === 'pickQuadrant' && selectedMatrix && ( +
+
+ + + + {selectedMatrix.name} + +
+

+ {t('matrix.actions.pickQuadrant', 'Pick a quadrant')} +

+
+ +
+
+ +
+
+ )} + + {/* --- Idle: placements list or empty state --- */} + {addStep === 'idle' && ( + <> + {placements.length > 0 ? ( +
+ {placements.map((p) => ( + +
+
+
+ + {p.matrix.name} + + + {quadrantLabel(p.matrix, p.quadrant_index)} + +
+
+ + + ))} + +
+ ) : ( +
+
+ + + {t('matrix.actions.assign', 'Add to matrix')} + +
+
+ )} + + )} +
+ ); +}; + +export default TaskMatrixCard; diff --git a/frontend/components/Task/TaskDetails/index.ts b/frontend/components/Task/TaskDetails/index.ts index a0dd0f57e..3f7b44d7e 100644 --- a/frontend/components/Task/TaskDetails/index.ts +++ b/frontend/components/Task/TaskDetails/index.ts @@ -8,3 +8,4 @@ export { default as TaskRecurrenceCard } from './TaskRecurrenceCard'; export { default as TaskDueDateCard } from './TaskDueDateCard'; export { default as TaskDeferUntilCard } from './TaskDeferUntilCard'; export { default as TaskAttachmentsCard } from './TaskAttachmentsCard'; +export { default as TaskMatrixCard } from './TaskMatrixCard'; diff --git a/frontend/components/Task/TaskHeader.tsx b/frontend/components/Task/TaskHeader.tsx index a63ca2f12..8246e3cba 100644 --- a/frontend/components/Task/TaskHeader.tsx +++ b/frontend/components/Task/TaskHeader.tsx @@ -11,6 +11,7 @@ import { import { TagIcon, FolderIcon, FireIcon } from '@heroicons/react/24/solid'; import { useTranslation } from 'react-i18next'; import TaskPriorityIcon from '../Shared/Icons/TaskPriorityIcon'; +import QuadrantDot from '../Shared/QuadrantDot'; import { Project } from '../../entities/Project'; import { Task } from '../../entities/Task'; import { fetchSubtasks } from '../../utils/tasksService'; @@ -203,6 +204,7 @@ const TaskHeader: React.FC = ({ title="Habit" /> )} + {task.original_name || task.name} @@ -298,6 +300,7 @@ const TaskHeader: React.FC = ({ title="Habit" /> )} + {task.original_name || task.name} diff --git a/frontend/constants/matrixColors.ts b/frontend/constants/matrixColors.ts new file mode 100644 index 000000000..b33fe6107 --- /dev/null +++ b/frontend/constants/matrixColors.ts @@ -0,0 +1,69 @@ +/** + * Unified quadrant color definitions used across the matrix feature. + * + * Quadrant indices map to positions in a 2×2 grid: + * Q0 = top-left (e.g., Urgent + Important → "Do First") + * Q1 = top-right (e.g., Not Urgent + Important → "Schedule") + * Q2 = bottom-left (e.g., Urgent + Not Important → "Delegate") + * Q3 = bottom-right (e.g., Not Urgent + Not Important → "Eliminate") + * + * Color semantics: red → amber → blue → green (urgency gradient). + */ + +export interface QuadrantStyle { + /** Solid dot/indicator color (single shade, e.g. 'bg-rose-500'). */ + dot: string; + /** Background fill with dark-mode variant (e.g. 'bg-rose-400 dark:bg-rose-500'). */ + bg: string; + /** Subtle background for cards/chips (e.g. 'bg-rose-50 dark:bg-rose-900/20'). */ + bgSubtle: string; + /** Text color with dark-mode variant. */ + text: string; + /** Focus ring color with dark-mode variant. */ + ring: string; +} + +export const QUADRANT_STYLES: Record = { + 0: { + dot: 'bg-rose-500', + bg: 'bg-rose-400 dark:bg-rose-500', + bgSubtle: 'bg-rose-50 dark:bg-rose-900/20', + text: 'text-rose-700 dark:text-rose-300', + ring: 'ring-rose-300 dark:ring-rose-700', + }, + 1: { + dot: 'bg-amber-500', + bg: 'bg-amber-400 dark:bg-amber-500', + bgSubtle: 'bg-amber-50 dark:bg-amber-900/20', + text: 'text-amber-700 dark:text-amber-300', + ring: 'ring-amber-300 dark:ring-amber-700', + }, + 2: { + dot: 'bg-sky-500', + bg: 'bg-sky-400 dark:bg-sky-500', + bgSubtle: 'bg-sky-50 dark:bg-sky-900/20', + text: 'text-sky-700 dark:text-sky-300', + ring: 'ring-sky-300 dark:ring-sky-700', + }, + 3: { + dot: 'bg-emerald-500', + bg: 'bg-emerald-400 dark:bg-emerald-500', + bgSubtle: 'bg-emerald-50 dark:bg-emerald-900/20', + text: 'text-emerald-700 dark:text-emerald-300', + ring: 'ring-emerald-300 dark:ring-emerald-700', + }, +}; + +/** Fallback when quadrant index is unknown. */ +export const QUADRANT_STYLE_DEFAULT: QuadrantStyle = { + dot: 'bg-gray-400', + bg: 'bg-gray-300 dark:bg-gray-600', + bgSubtle: 'bg-gray-50 dark:bg-gray-800', + text: 'text-gray-600 dark:text-gray-400', + ring: 'ring-gray-300 dark:ring-gray-600', +}; + +/** Get the style for a quadrant index, falling back to gray for unknown indices. */ +export function getQuadrantStyle(index: number): QuadrantStyle { + return QUADRANT_STYLES[index] ?? QUADRANT_STYLE_DEFAULT; +} diff --git a/frontend/contexts/MatrixPlacementsContext.tsx b/frontend/contexts/MatrixPlacementsContext.tsx new file mode 100644 index 000000000..88d9ad06d --- /dev/null +++ b/frontend/contexts/MatrixPlacementsContext.tsx @@ -0,0 +1,70 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, + useMemo, +} from 'react'; +import { TaskPlacementSummary } from '../entities/Matrix'; +import { fetchAllPlacements } from '../utils/matrixService'; + +interface MatrixPlacementsContextValue { + /** Map from task_id → array of placements */ + placementsByTask: Map; + /** Whether the initial fetch is still loading */ + isLoading: boolean; + /** Reload placements from server */ + reload: () => void; +} + +const MatrixPlacementsContext = createContext({ + placementsByTask: new Map(), + isLoading: true, + reload: () => {}, +}); + +export const useMatrixPlacements = () => useContext(MatrixPlacementsContext); + +export const MatrixPlacementsProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const [placements, setPlacements] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const load = useCallback(async () => { + try { + const result = await fetchAllPlacements(); + setPlacements(result.data || []); + } catch { + // Silently fail — dots just won't show + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const placementsByTask = useMemo(() => { + const map = new Map(); + for (const p of placements) { + const existing = map.get(p.task_id) || []; + existing.push(p); + map.set(p.task_id, existing); + } + return map; + }, [placements]); + + const value = useMemo( + () => ({ placementsByTask, isLoading, reload: load }), + [placementsByTask, isLoading, load] + ); + + return ( + + {children} + + ); +}; diff --git a/frontend/entities/Matrix.ts b/frontend/entities/Matrix.ts new file mode 100644 index 000000000..df75c039d --- /dev/null +++ b/frontend/entities/Matrix.ts @@ -0,0 +1,67 @@ +import { Project } from './Project'; + +export interface Matrix { + id?: number; + uid?: string; + name: string; + project_id?: number | null; + user_id?: number; + x_axis_label_left: string; + x_axis_label_right: string; + y_axis_label_top: string; + y_axis_label_bottom: string; + project?: Pick; + taskCount?: number; + created_at?: string; + updated_at?: string; +} + +export interface MatrixTask { + id: number; + uid?: string; + name: string; + status: number | string; + priority?: number | string | null; + due_date?: string; + project_id?: number; + tags?: { id: number; uid?: string; name: string }[]; + TaskMatrix?: { + quadrant_index: 0 | 1 | 2 | 3; + position: number; + }; +} + +export interface MatrixDetail extends Matrix { + quadrants: { + [key: string]: MatrixTask[]; + }; + unassigned: MatrixTask[]; +} + +export interface TaskMatrixAssignment { + task_id: number; + matrix_id: number; + quadrant_index: 0 | 1 | 2 | 3; + position: number; + created_at?: string; + updated_at?: string; +} + +/** + * A matrix placement for a task — returned by GET /tasks/:taskId/matrices. + */ +export interface TaskMatrixPlacement { + matrix: Matrix; + quadrant_index: 0 | 1 | 2 | 3; + position: number; +} + +/** + * Lightweight placement summary for dot indicators — returned by GET /matrices/placements. + */ +export interface TaskPlacementSummary { + task_id: number; + matrix_id: number; + quadrant_index: 0 | 1 | 2 | 3; + matrix_name: string | null; +} diff --git a/frontend/hooks/useMatrix.ts b/frontend/hooks/useMatrix.ts new file mode 100644 index 000000000..9c819a290 --- /dev/null +++ b/frontend/hooks/useMatrix.ts @@ -0,0 +1,245 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + fetchMatrices, + fetchMatrix, + createMatrix, + updateMatrix, + deleteMatrix, + assignTaskToMatrix, + removeTaskFromMatrix, +} from '../utils/matrixService'; +import { Matrix, MatrixDetail, MatrixTask } from '../entities/Matrix'; + +/** + * Deep-clone a MatrixDetail's quadrants & unassigned arrays for rollback. + */ +export function snapshotMatrix(m: MatrixDetail): MatrixDetail { + return { + ...m, + quadrants: Object.fromEntries( + Object.entries(m.quadrants).map(([k, v]) => [k, [...v]]) + ), + unassigned: [...m.unassigned], + }; +} + +/** + * Find and remove a task from quadrants by ID. + * Returns the removed task (shallow copy) or null if not found. + */ +export function extractTaskFromQuadrants( + quadrants: Record, + taskId: number +): MatrixTask | null { + for (const qi of Object.keys(quadrants)) { + const idx = quadrants[qi].findIndex((t) => t.id === taskId); + if (idx !== -1) { + const task = { ...quadrants[qi][idx] }; + quadrants[qi] = quadrants[qi].filter((_, i) => i !== idx); + return task; + } + } + return null; +} + +/** + * Hook for the matrix list page. + */ +export function useMatrices(projectId?: number) { + const [matrices, setMatrices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + const load = useCallback(async () => { + setIsLoading(true); + setIsError(false); + try { + const result = await fetchMatrices(projectId); + setMatrices(result.data || []); + } catch { + setIsError(true); + } finally { + setIsLoading(false); + } + }, [projectId]); + + useEffect(() => { + load(); + }, [load]); + + const handleCreate = useCallback( + async (data: Partial) => { + const result = await createMatrix(data); + await load(); + return result.data; + }, + [load] + ); + + const handleDelete = useCallback( + async (matrixId: number) => { + await deleteMatrix(matrixId); + await load(); + }, + [load] + ); + + return { + matrices, + isLoading, + isError, + reload: load, + createMatrix: handleCreate, + deleteMatrix: handleDelete, + }; +} + +/** + * Hook for a single matrix detail page with optimistic updates. + */ +export function useMatrix(matrixId: number | null) { + const [matrix, setMatrix] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + /** + * Fetch matrix data from the server. + * Never flips isLoading after the first successful load — stale data + * stays visible while we revalidate in the background. + */ + const load = useCallback(async () => { + if (!matrixId) return; + try { + const result = await fetchMatrix(matrixId); + setMatrix(result.data); + setIsError(false); + } catch { + // Only show error state if we never had data + setIsError(true); + } finally { + setIsLoading(false); + } + }, [matrixId]); + + useEffect(() => { + setIsLoading(true); + setIsError(false); + load(); + }, [load]); + + /** + * Move a task to a new quadrant with optimistic update. + */ + const moveTask = useCallback( + async (taskId: number, newQuadrantIndex: number) => { + if (!matrix || !matrixId) return; + + const previousMatrix = snapshotMatrix(matrix); + + // Optimistic update: move the task in local state + const updatedQuadrants = { ...matrix.quadrants }; + let movedTask = extractTaskFromQuadrants(updatedQuadrants, taskId); + + // Check unassigned list too + let updatedUnassigned = [...matrix.unassigned]; + if (!movedTask) { + const idx = updatedUnassigned.findIndex( + (t) => t.id === taskId + ); + if (idx !== -1) { + movedTask = { ...updatedUnassigned[idx] }; + updatedUnassigned = updatedUnassigned.filter( + (_, i) => i !== idx + ); + } + } + + if (movedTask) { + movedTask.TaskMatrix = { + quadrant_index: newQuadrantIndex as 0 | 1 | 2 | 3, + position: 0, + }; + const targetKey = String(newQuadrantIndex); + updatedQuadrants[targetKey] = [ + ...(updatedQuadrants[targetKey] || []), + movedTask, + ]; + } + + setMatrix({ + ...matrix, + quadrants: updatedQuadrants, + unassigned: updatedUnassigned, + }); + + try { + await assignTaskToMatrix(matrixId, taskId, newQuadrantIndex); + // If task wasn't in local state (e.g., from browsed sidebar), + // reload to get fresh data. Safe because load() no longer flashes. + if (!movedTask) { + await load(); + } + } catch { + // Rollback on error + setMatrix(previousMatrix); + } + }, + [matrix, matrixId, load] + ); + + /** + * Remove a task from the matrix. + */ + const removeTask = useCallback( + async (taskId: number) => { + if (!matrix || !matrixId) return; + + const previousMatrix = snapshotMatrix(matrix); + + // Optimistic: remove from quadrants, add to unassigned + const updatedQuadrants = { ...matrix.quadrants }; + const removedTask = extractTaskFromQuadrants(updatedQuadrants, taskId); + + const updatedUnassigned = [...matrix.unassigned]; + if (removedTask) { + delete removedTask.TaskMatrix; + updatedUnassigned.push(removedTask); + } + + setMatrix({ + ...matrix, + quadrants: updatedQuadrants, + unassigned: updatedUnassigned, + }); + + try { + await removeTaskFromMatrix(matrixId, taskId); + } catch { + setMatrix(previousMatrix); + } + }, + [matrix, matrixId] + ); + + /** + * Update the matrix details (name, labels). + */ + const update = useCallback( + async (data: Partial) => { + if (!matrixId) return; + await updateMatrix(matrixId, data); + await load(); + }, + [matrixId, load] + ); + + return { + matrix, + isLoading, + isError, + reload: load, + moveTask, + removeTask, + updateMatrix: update, + }; +} diff --git a/frontend/store/useMatrixStore.ts b/frontend/store/useMatrixStore.ts new file mode 100644 index 000000000..534898a44 --- /dev/null +++ b/frontend/store/useMatrixStore.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand'; + +export type SidebarCategory = 'project' | 'area' | 'tag' | null; + +interface MatrixUIState { + activeDragTaskId: number | null; + setActiveDragTaskId: (id: number | null) => void; + + sidebarSearchQuery: string; + setSidebarSearchQuery: (query: string) => void; + + sidebarCategory: SidebarCategory; + setSidebarCategory: (cat: SidebarCategory) => void; + + sidebarSourceId: string | null; + setSidebarSourceId: (id: string | null) => void; +} + +export const useMatrixStore = create((set) => ({ + activeDragTaskId: null, + setActiveDragTaskId: (id) => set({ activeDragTaskId: id }), + + sidebarSearchQuery: '', + setSidebarSearchQuery: (query) => set({ sidebarSearchQuery: query }), + + sidebarCategory: null, + setSidebarCategory: (cat) => set({ sidebarCategory: cat, sidebarSourceId: null, sidebarSearchQuery: '' }), + + sidebarSourceId: null, + setSidebarSourceId: (id) => set({ sidebarSourceId: id, sidebarSearchQuery: '' }), +})); diff --git a/frontend/styles/tailwind.css b/frontend/styles/tailwind.css index a3e0e2c06..65b456e01 100644 --- a/frontend/styles/tailwind.css +++ b/frontend/styles/tailwind.css @@ -170,6 +170,30 @@ select:focus { display: none; /* Chrome/Safari */ } +/* Thin minimal scrollbar for matrix views */ +.scrollbar-thin { + scrollbar-width: thin; /* Firefox */ + scrollbar-color: rgba(156,163,175,0.3) transparent; +} + +.scrollbar-thin::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +.scrollbar-thin::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-thin::-webkit-scrollbar-thumb { + background-color: rgba(156,163,175,0.3); + border-radius: 9999px; +} + +.scrollbar-thin::-webkit-scrollbar-thumb:hover { + background-color: rgba(156,163,175,0.5); +} + @layer utilities { .line-clamp-1, .line-clamp-2, diff --git a/frontend/utils/matrixService.ts b/frontend/utils/matrixService.ts new file mode 100644 index 000000000..970e8739e --- /dev/null +++ b/frontend/utils/matrixService.ts @@ -0,0 +1,191 @@ +import { getApiPath } from '../config/paths'; +import { + Matrix, + MatrixDetail, + MatrixTask, + TaskMatrixAssignment, + TaskMatrixPlacement, + TaskPlacementSummary, +} from '../entities/Matrix'; + +const defaultHeaders = { + 'Content-Type': 'application/json', + Accept: 'application/json', +}; + +const fetchOptions: RequestInit = { + credentials: 'include' as RequestCredentials, + headers: defaultHeaders, +}; + +/** + * Fetch all matrices, optionally filtered by project. + */ +export async function fetchMatrices( + projectId?: number +): Promise<{ success: boolean; data: Matrix[] }> { + const url = projectId + ? getApiPath(`matrices?project_id=${projectId}`) + : getApiPath('matrices'); + + const response = await fetch(url, fetchOptions); + if (!response.ok) throw new Error('Failed to fetch matrices'); + return response.json(); +} + +/** + * Fetch a single matrix with tasks grouped by quadrant. + */ +export async function fetchMatrix( + matrixId: number +): Promise<{ success: boolean; data: MatrixDetail }> { + const response = await fetch( + getApiPath(`matrices/${matrixId}`), + fetchOptions + ); + if (!response.ok) throw new Error('Failed to load matrix'); + return response.json(); +} + +/** + * Create a new matrix. + */ +export async function createMatrix( + data: Partial +): Promise<{ success: boolean; data: Matrix }> { + const response = await fetch(getApiPath('matrices'), { + ...fetchOptions, + method: 'POST', + body: JSON.stringify(data), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.message || 'Failed to create matrix'); + } + return response.json(); +} + +/** + * Update a matrix. + */ +export async function updateMatrix( + matrixId: number, + data: Partial +): Promise<{ success: boolean; data: Matrix }> { + const response = await fetch(getApiPath(`matrices/${matrixId}`), { + ...fetchOptions, + method: 'PUT', + body: JSON.stringify(data), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.message || 'Failed to update matrix'); + } + return response.json(); +} + +/** + * Delete a matrix. + */ +export async function deleteMatrix( + matrixId: number +): Promise<{ success: boolean; message: string }> { + const response = await fetch(getApiPath(`matrices/${matrixId}`), { + ...fetchOptions, + method: 'DELETE', + }); + if (!response.ok) throw new Error('Failed to delete matrix'); + return response.json(); +} + +/** + * Assign or move a task in a matrix. + */ +export async function assignTaskToMatrix( + matrixId: number, + taskId: number, + quadrantIndex: number, + position?: number +): Promise<{ success: boolean; data: TaskMatrixAssignment; message: string }> { + const response = await fetch( + getApiPath(`matrices/${matrixId}/tasks/${taskId}`), + { + ...fetchOptions, + method: 'PUT', + body: JSON.stringify({ + quadrant_index: quadrantIndex, + position: position || 0, + }), + } + ); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.message || 'Failed to assign task to matrix'); + } + return response.json(); +} + +/** + * Remove a task from a matrix. + */ +export async function removeTaskFromMatrix( + matrixId: number, + taskId: number +): Promise<{ success: boolean; message: string }> { + const response = await fetch( + getApiPath(`matrices/${matrixId}/tasks/${taskId}`), + { + ...fetchOptions, + method: 'DELETE', + } + ); + if (!response.ok) throw new Error('Failed to remove task from matrix'); + return response.json(); +} + +/** + * Fetch all matrix placements for a specific task. + */ +export async function fetchTaskMatrices( + taskId: number +): Promise<{ success: boolean; data: TaskMatrixPlacement[] }> { + const response = await fetch( + getApiPath(`tasks/${taskId}/matrices`), + fetchOptions + ); + if (!response.ok) throw new Error('Failed to fetch task matrices'); + return response.json(); +} + +/** + * Fetch all task-to-matrix placements for the authenticated user (bulk). + */ +export async function fetchAllPlacements(): Promise<{ + success: boolean; + data: TaskPlacementSummary[]; +}> { + const response = await fetch( + getApiPath('matrices/placements'), + fetchOptions + ); + if (!response.ok) throw new Error('Failed to fetch placements'); + return response.json(); +} + +export type BrowseSource = 'project' | 'area' | 'tag'; + +/** + * Browse available tasks for a matrix, filtered by source category. + */ +export async function browseMatrixTasks( + matrixId: number, + source: BrowseSource, + sourceId: string | number +): Promise<{ success: boolean; data: MatrixTask[] }> { + const response = await fetch( + getApiPath(`matrices/${matrixId}/browse?source=${source}&sourceId=${sourceId}`), + fetchOptions + ); + if (!response.ok) throw new Error('Failed to browse tasks'); + return response.json(); +} diff --git a/package-lock.json b/package-lock.json index 43c759298..fe7f0d1c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tududi", - "version": "v0.88.5-dev.1", + "version": "v0.88.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tududi", - "version": "v0.88.5-dev.1", + "version": "v0.88.5", "license": "ISC", "dependencies": { "@playwright/test": "^1.57.0", @@ -19053,6 +19053,24 @@ "node": ">=4" } }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", diff --git a/package.json b/package.json index 469716491..d24de27b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tududi", - "version": "v0.88.5", + "version": "v1.0.0", "description": "Self-hosted task management with hierarchical organization, multi-language support, and Telegram integration.", "directories": { "test": "test" @@ -8,6 +8,7 @@ "scripts": { "start": "bash scripts/start-all-dev.sh", "dev": "npm run frontend:dev", + "dev:local": "bash scripts/start-dev-local.sh", "build": "npm run frontend:build", "pre-push": "lint-staged", "pre-release": "npm run lint:fix && npm run format:fix && npm run test && npm run test:ui", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index bb3b20518..d2e61e386 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -54,7 +54,8 @@ "completed": "Completed", "allTasks": "All Tasks", "unpinView": "Unpin view", - "pinView": "Pin view" + "pinView": "Pin view", + "matrices": "Matrices" }, "navigation": { "home": "Home", @@ -1346,5 +1347,99 @@ "title": "Note:", "message": "Email and Push notifications are coming soon. In-app and Telegram notifications are currently available." } + }, + "matrix": { + "title": "Matrices", + "pageDescription": "Prioritize tasks visually with custom 2×2 grids", + "create": "Create Matrix", + "edit": "Edit Matrix", + "settings": "Matrix Settings", + "openMatrix": "Open Matrix", + "name": "Matrix Name", + "namePlaceholder": "e.g., Eisenhower Matrix", + "xAxisLeft": "X-Axis Left Label", + "xAxisLeftPlaceholder": "e.g., Not Urgent", + "xAxisRight": "X-Axis Right Label", + "xAxisRightPlaceholder": "e.g., Urgent", + "yAxisTop": "Y-Axis Top Label", + "yAxisTopPlaceholder": "e.g., Important", + "yAxisBottom": "Y-Axis Bottom Label", + "yAxisBottomPlaceholder": "e.g., Not Important", + "defaultXAxisLeft": "Low Effort", + "defaultXAxisRight": "High Effort", + "defaultYAxisTop": "High Impact", + "defaultYAxisBottom": "Low Impact", + "project": "Linked Project", + "projectNone": "No project (standalone)", + "projectSelect": "Select a project...", + "preset": { + "label": "Quick Preset", + "eisenhower": "Eisenhower", + "effortImpact": "Effort / Impact", + "riskReward": "Risk / Reward" + }, + "quadrant": { + "empty": "Drop tasks here", + "topLeft": "Top Left", + "topRight": "Top Right", + "bottomLeft": "Bottom Left", + "bottomRight": "Bottom Right", + "quickAdd": "Add task", + "taskNamePlaceholder": "Task name..." + }, + "sidebar": { + "browseTitle": "Browse Tasks", + "categoryProjects": "Projects", + "categoryAreas": "Areas", + "categoryTags": "Tags", + "selectProject": "Select project...", + "selectArea": "Select area...", + "selectTag": "Select tag...", + "selectCategory": "Select a category above to browse tasks", + "taskCount": "tasks", + "title": "Unassigned Tasks", + "empty": "All tasks have been placed", + "search": "Search tasks..." + }, + "card": { + "taskCount": "{{count}} task", + "taskCount_plural": "{{count}} tasks", + "noTasks": "No tasks assigned yet", + "viewMatrix": "View Matrix" + }, + "actions": { + "dragHint": "Drag and drop tasks to prioritize", + "assign": "Add to matrix", + "remove": "Remove from matrix", + "moveToQuadrant": "Move to {{quadrant}}", + "pickQuadrant": "Pick a quadrant", + "selectMatrix": "Select a matrix", + "noMatricesAvailable": "No more matrices available" + }, + "delete": { + "title": "Delete Matrix", + "confirm": "Delete this matrix?", + "description": "Tasks will not be deleted. They will only be removed from this matrix.", + "success": "Matrix deleted successfully" + }, + "errors": { + "notFound": "Matrix not found", + "loadFailed": "Failed to load matrix", + "saveFailed": "Failed to save matrix", + "assignFailed": "Failed to assign task to matrix", + "nameRequired": "Matrix name is required" + }, + "empty": { + "title": "No matrices yet", + "description": "Create your first 2×2 matrix to start prioritizing tasks visually.", + "createFirst": "Create your first matrix" + }, + "toast": { + "created": "Matrix \"{{name}}\" created", + "updated": "Matrix updated", + "taskAdded": "Task added to matrix", + "taskMoved": "Task moved to {{quadrant}}", + "taskRemoved": "Task removed from matrix" + } } } diff --git a/scripts/start-dev-local.sh b/scripts/start-dev-local.sh new file mode 100755 index 000000000..299fde4b8 --- /dev/null +++ b/scripts/start-dev-local.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# Start tududi dev servers on separate ports from production. +# +# Production (Docker): backend=3002, frontend via Traefik +# Development (local): backend=3003, frontend=8081 +# +# The dev instance uses the same development.sqlite3 database. +# Press Ctrl+C to stop both servers. +# +set -euo pipefail + +export NODE_ENV=development +export PORT=3003 +export FRONTEND_PORT=8081 +export BACKEND_URL=http://localhost:3003 +export FRONTEND_ORIGIN=http://localhost:8081 + +echo "" +echo "================================================" +echo " tududi DEV instance" +echo " Backend: http://localhost:$PORT" +echo " Frontend: http://localhost:$FRONTEND_PORT" +echo "================================================" +echo "" + +cleanup() { + echo "" + echo "Stopping dev servers..." + local pids + pids="$(jobs -p || true)" + if [ -n "$pids" ]; then + kill $pids 2>/dev/null || true + wait $pids 2>/dev/null || true + fi +} +trap cleanup INT TERM EXIT + +echo "Starting dev backend on port $PORT..." +(cd backend && PORT=$PORT nodemon app.js) & + +echo "Starting dev frontend on port $FRONTEND_PORT..." +FRONTEND_PORT=$FRONTEND_PORT BACKEND_URL=$BACKEND_URL FRONTEND_ORIGIN=$FRONTEND_ORIGIN \ + npx webpack serve --config webpack.config.js --hot & + +wait