Reorder elements (#687)

* Reorder elements

* Enhance global search
This commit is contained in:
Chris 2025-12-09 10:51:51 +02:00 committed by GitHub
parent bf281b740d
commit b0b613f7bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 1313 additions and 454 deletions

View file

@ -0,0 +1,34 @@
'use strict';
const {
safeAddColumns,
safeRemoveColumn,
} = require('../utils/migration-utils');
module.exports = {
up: async (queryInterface, Sequelize) => {
await safeAddColumns(queryInterface, 'views', [
{
name: 'defer',
definition: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Defer timeframe filter',
},
},
{
name: 'extras',
definition: {
type: Sequelize.TEXT,
allowNull: true,
comment: 'JSON array of extras filters',
},
},
]);
},
down: async (queryInterface) => {
await safeRemoveColumn(queryInterface, 'views', 'extras');
await safeRemoveColumn(queryInterface, 'views', 'defer');
},
};

View file

@ -51,6 +51,10 @@ module.exports = (sequelize) => {
type: DataTypes.STRING,
allowNull: true,
},
defer: {
type: DataTypes.STRING,
allowNull: true,
},
tags: {
type: DataTypes.TEXT,
allowNull: true,
@ -62,6 +66,17 @@ module.exports = (sequelize) => {
this.setDataValue('tags', JSON.stringify(value));
},
},
extras: {
type: DataTypes.TEXT,
allowNull: true,
get() {
const rawValue = this.getDataValue('extras');
return rawValue ? JSON.parse(rawValue) : [];
},
set(value) {
this.setDataValue('extras', JSON.stringify(value));
},
},
recurring: {
type: DataTypes.STRING,
allowNull: true,

View file

@ -27,6 +27,7 @@ const priorityToInt = (priorityStr) => {
* - due: filter by due date (today,tomorrow,next_week,next_month)
* - tags: comma-separated list of tag names to filter by
* - recurring: filter by recurrence type (recurring,non_recurring,instances)
* - extras: comma-separated list of extra filters (recurring,overdue,has_content,deferred,has_tags,assigned_to_project)
* - limit: number of results to return (default: 20)
* - offset: number of results to skip (default: 0)
* - excludeSubtasks: if 'true', exclude tasks that have a parent_task_id or recurring_parent_id
@ -46,6 +47,7 @@ router.get('/', async (req, res) => {
defer,
tags: tagsParam,
recurring,
extras: extrasParam,
limit: limitParam,
offset: offsetParam,
excludeSubtasks,
@ -57,6 +59,18 @@ router.get('/', async (req, res) => {
const tagNames = tagsParam
? tagsParam.split(',').map((t) => t.trim())
: [];
const extras =
extrasParam && typeof extrasParam === 'string'
? extrasParam
.split(',')
.map((extra) => extra.trim())
.filter(Boolean)
: [];
const extrasSet = new Set(extras);
const userTimezone = req.currentUser?.timezone || 'UTC';
const nowMoment = moment().tz(userTimezone);
const startOfToday = nowMoment.clone().startOf('day');
const nowDate = nowMoment.toDate();
// Pagination support
const hasPagination =
@ -88,35 +102,31 @@ router.get('/', async (req, res) => {
// Calculate due date range based on filter
let dueDateCondition = null;
if (due) {
const now = moment().startOf('day');
let startDate, endDate;
switch (due) {
case 'today':
startDate = now.clone();
endDate = now.clone().endOf('day');
startDate = startOfToday.clone();
endDate = startOfToday.clone().endOf('day');
break;
case 'tomorrow':
startDate = now.clone().add(1, 'day');
endDate = now.clone().add(1, 'day').endOf('day');
startDate = startOfToday.clone().add(1, 'day');
endDate = startOfToday.clone().add(1, 'day').endOf('day');
break;
case 'next_week':
startDate = now.clone();
endDate = now.clone().add(7, 'days').endOf('day');
startDate = startOfToday.clone();
endDate = startOfToday.clone().add(7, 'days').endOf('day');
break;
case 'next_month':
startDate = now.clone();
endDate = now.clone().add(1, 'month').endOf('day');
startDate = startOfToday.clone();
endDate = startOfToday.clone().add(1, 'month').endOf('day');
break;
}
if (startDate && endDate) {
dueDateCondition = {
due_date: {
[Op.between]: [
startDate.toISOString(),
endDate.toISOString(),
],
[Op.between]: [startDate.toDate(), endDate.toDate()],
},
};
}
@ -125,35 +135,31 @@ router.get('/', async (req, res) => {
// Calculate defer until date range based on filter
let deferDateCondition = null;
if (defer) {
const now = moment().startOf('day');
let startDate, endDate;
switch (defer) {
case 'today':
startDate = now.clone();
endDate = now.clone().endOf('day');
startDate = startOfToday.clone();
endDate = startOfToday.clone().endOf('day');
break;
case 'tomorrow':
startDate = now.clone().add(1, 'day');
endDate = now.clone().add(1, 'day').endOf('day');
startDate = startOfToday.clone().add(1, 'day');
endDate = startOfToday.clone().add(1, 'day').endOf('day');
break;
case 'next_week':
startDate = now.clone();
endDate = now.clone().add(7, 'days').endOf('day');
startDate = startOfToday.clone();
endDate = startOfToday.clone().add(7, 'days').endOf('day');
break;
case 'next_month':
startDate = now.clone();
endDate = now.clone().add(1, 'month').endOf('day');
startDate = startOfToday.clone();
endDate = startOfToday.clone().add(1, 'month').endOf('day');
break;
}
if (startDate && endDate) {
deferDateCondition = {
defer_until: {
[Op.between]: [
startDate.toISOString(),
endDate.toISOString(),
],
[Op.between]: [startDate.toDate(), endDate.toDate()],
},
};
}
@ -164,6 +170,7 @@ router.get('/', async (req, res) => {
const taskConditions = {
user_id: userId,
};
const taskExtraConditions = [];
// Exclude subtasks and recurring instances if requested
if (excludeSubtasks === 'true') {
@ -196,12 +203,12 @@ router.get('/', async (req, res) => {
// Add due date filter if specified
if (dueDateCondition) {
Object.assign(taskConditions, dueDateCondition);
taskExtraConditions.push(dueDateCondition);
}
// Add defer until filter if specified
if (deferDateCondition) {
Object.assign(taskConditions, deferDateCondition);
taskExtraConditions.push(deferDateCondition);
}
// Add recurring filter if specified
@ -227,6 +234,64 @@ router.get('/', async (req, res) => {
}
}
if (extrasSet.has('recurring')) {
taskExtraConditions.push({
[Op.or]: [
{ recurrence_type: { [Op.ne]: 'none' } },
{ recurring_parent_id: { [Op.ne]: null } },
],
});
}
if (extrasSet.has('overdue')) {
taskExtraConditions.push({
due_date: { [Op.lt]: nowDate },
});
taskExtraConditions.push({
completed_at: null,
});
}
if (extrasSet.has('has_content')) {
const noteHasContent = sequelize.where(
sequelize.fn(
'LENGTH',
sequelize.fn('TRIM', sequelize.col('Task.note'))
),
{ [Op.gt]: 0 }
);
const descriptionHasContent = sequelize.where(
sequelize.fn(
'LENGTH',
sequelize.fn('TRIM', sequelize.col('Task.description'))
),
{ [Op.gt]: 0 }
);
taskExtraConditions.push({
[Op.or]: [noteHasContent, descriptionHasContent],
});
}
if (extrasSet.has('deferred')) {
taskExtraConditions.push({
defer_until: { [Op.gt]: nowDate },
});
}
if (extrasSet.has('assigned_to_project')) {
taskExtraConditions.push({
project_id: { [Op.ne]: null },
});
}
if (taskExtraConditions.length > 0) {
if (taskConditions[Op.and]) {
taskConditions[Op.and].push(...taskExtraConditions);
} else {
taskConditions[Op.and] = taskExtraConditions;
}
}
const taskInclude = [
{
model: Project,
@ -245,32 +310,28 @@ router.get('/', async (req, res) => {
},
];
// Add tag filter if specified
const requireTags = tagIds.length > 0 || extrasSet.has('has_tags');
const tagInclude = {
model: Tag,
through: { attributes: [] },
attributes: ['id', 'name', 'uid'],
required: requireTags,
};
if (tagIds.length > 0) {
taskInclude.push({
model: Tag,
where: {
id: { [Op.in]: tagIds },
},
through: { attributes: [] },
attributes: ['id', 'name', 'uid'],
required: true,
});
} else {
// Always include tags for display, even if not filtering
taskInclude.push({
model: Tag,
through: { attributes: [] },
attributes: ['id', 'name', 'uid'],
required: false,
});
tagInclude.where = {
id: { [Op.in]: tagIds },
};
}
taskInclude.push(tagInclude);
// Count total tasks if pagination is requested
if (hasPagination) {
const countInclude = requireTags ? [tagInclude] : undefined;
totalCount += await Task.count({
where: taskConditions,
include: tagIds.length > 0 ? taskInclude : undefined,
include: countInclude,
distinct: true,
});
}
@ -333,19 +394,25 @@ router.get('/', async (req, res) => {
Object.assign(projectConditions, projectDueCondition);
}
const requireProjectTags =
tagIds.length > 0 || extrasSet.has('has_tags');
const projectInclude = [];
// Add tag filter if specified
if (tagIds.length > 0) {
projectInclude.push({
if (requireProjectTags) {
const projectTagInclude = {
model: Tag,
where: {
id: { [Op.in]: tagIds },
},
through: { attributes: [] },
attributes: [],
required: true,
});
};
if (tagIds.length > 0) {
projectTagInclude.where = {
id: { [Op.in]: tagIds },
};
}
projectInclude.push(projectTagInclude);
}
// Count total projects if pagination is requested

View file

@ -64,8 +64,17 @@ router.get('/:identifier', async (req, res) => {
// POST /api/views - Create a new view
router.post('/', async (req, res) => {
try {
const { name, search_query, filters, priority, due, tags, recurring } =
req.body;
const {
name,
search_query,
filters,
priority,
due,
defer,
tags,
extras,
recurring,
} = req.body;
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'View name is required' });
@ -78,7 +87,9 @@ router.post('/', async (req, res) => {
filters: filters || [],
priority: priority || null,
due: due || null,
defer: defer || null,
tags: tags || [],
extras: extras || [],
recurring: recurring || null,
is_pinned: false,
});
@ -114,7 +125,9 @@ router.patch('/:identifier', async (req, res) => {
filters,
priority,
due,
defer,
tags,
extras,
recurring,
is_pinned,
} = req.body;
@ -125,7 +138,9 @@ router.patch('/:identifier', async (req, res) => {
if (filters !== undefined) updates.filters = filters;
if (priority !== undefined) updates.priority = priority;
if (due !== undefined) updates.due = due;
if (defer !== undefined) updates.defer = defer;
if (tags !== undefined) updates.tags = tags;
if (extras !== undefined) updates.extras = extras;
if (recurring !== undefined) updates.recurring = recurring;
if (is_pinned !== undefined) updates.is_pinned = is_pinned;

View file

@ -629,7 +629,9 @@ async function importUserData(userId, backupData, options = { merge: true }) {
filters: viewData.filters,
priority: viewData.priority,
due: viewData.due,
defer: viewData.defer,
tags: viewData.tags,
extras: viewData.extras,
recurring: viewData.recurring,
is_pinned: viewData.is_pinned,
user_id: userId,

View file

@ -649,6 +649,197 @@ describe('Universal Search Routes', () => {
});
});
describe('Extras Filters', () => {
beforeEach(async () => {
const project = await Project.create({
user_id: user.id,
name: 'Extras Project',
state: 'active',
});
const workTag = await Tag.create({
user_id: user.id,
name: 'extras-tag',
});
const recurringTemplate = await Task.create({
user_id: user.id,
name: 'Recurring Template',
recurrence_type: 'weekly',
status: 0,
});
await Task.create({
user_id: user.id,
name: 'Recurring Instance',
recurring_parent_id: recurringTemplate.id,
status: 0,
});
await Task.create({
user_id: user.id,
name: 'Regular Task',
status: 0,
});
await Task.create({
user_id: user.id,
name: 'Overdue Task',
due_date: moment().subtract(2, 'days').toDate(),
status: 0,
});
await Task.create({
user_id: user.id,
name: 'Completed Overdue Task',
due_date: moment().subtract(3, 'days').toDate(),
completed_at: new Date(),
status: 3,
});
await Task.create({
user_id: user.id,
name: 'Content Task',
note: 'Detailed context lives here',
status: 0,
});
await Task.create({
user_id: user.id,
name: 'Deferred Task',
defer_until: moment().add(2, 'days').toDate(),
status: 0,
});
const taggedTask = await Task.create({
user_id: user.id,
name: 'Tagged Task',
status: 0,
});
await taggedTask.addTag(workTag);
await Task.create({
user_id: user.id,
name: 'Project Task',
project_id: project.id,
status: 0,
});
});
const getTaskNames = (response) =>
response.body.results
.filter((r) => r.type === 'Task')
.map((task) => task.original_name || task.name);
it('should return only recurring tasks when extras contains recurring', async () => {
const response = await agent.get('/api/search').query({
filters: 'Task',
extras: 'recurring',
});
expect(response.status).toBe(200);
const names = getTaskNames(response);
expect(names).toEqual(
expect.arrayContaining([
'Recurring Template',
'Recurring Instance',
])
);
expect(names).not.toContain('Regular Task');
});
it('should return overdue tasks and exclude completed ones', async () => {
const response = await agent.get('/api/search').query({
filters: 'Task',
extras: 'overdue',
});
expect(response.status).toBe(200);
const names = getTaskNames(response);
expect(names).toContain('Overdue Task');
expect(names).not.toContain('Completed Overdue Task');
});
it('should return tasks that have content', async () => {
const response = await agent.get('/api/search').query({
filters: 'Task',
extras: 'has_content',
});
expect(response.status).toBe(200);
const names = getTaskNames(response);
expect(names).toContain('Content Task');
expect(names).not.toContain('Regular Task');
});
it('should return deferred tasks', async () => {
const response = await agent.get('/api/search').query({
filters: 'Task',
extras: 'deferred',
});
expect(response.status).toBe(200);
const names = getTaskNames(response);
expect(names).toContain('Deferred Task');
expect(names).not.toContain('Regular Task');
});
it('should return tasks with tags', async () => {
const response = await agent.get('/api/search').query({
filters: 'Task',
extras: 'has_tags',
});
expect(response.status).toBe(200);
const names = getTaskNames(response);
expect(names).toContain('Tagged Task');
expect(names).not.toContain('Regular Task');
});
it('should return tasks assigned to projects', async () => {
const response = await agent.get('/api/search').query({
filters: 'Task',
extras: 'assigned_to_project',
});
expect(response.status).toBe(200);
const names = getTaskNames(response);
expect(names).toContain('Project Task');
expect(names).not.toContain('Regular Task');
});
it('should return only projects that have tags when extras include has_tags', async () => {
const taggedProject = await Project.create({
user_id: user.id,
name: 'Tagged Project',
state: 'active',
});
const plainProject = await Project.create({
user_id: user.id,
name: 'Plain Project',
state: 'active',
});
const projectTag = await Tag.create({
user_id: user.id,
name: 'project-tag',
});
await taggedProject.addTag(projectTag);
const response = await agent.get('/api/search').query({
filters: 'Project',
extras: 'has_tags',
});
expect(response.status).toBe(200);
const projectResults = response.body.results.filter(
(r) => r.type === 'Project'
);
const projectNames = projectResults.map((p) => p.name);
expect(projectNames).toContain('Tagged Project');
expect(projectNames).not.toContain('Plain Project');
});
});
describe('User Isolation', () => {
let otherUser, otherAgent;

View file

@ -76,7 +76,9 @@ describe('Views Routes', () => {
filters: ['Task', 'Project'],
priority: 'high',
due: 'today',
defer: 'tomorrow',
tags: ['work', 'important'],
extras: ['recurring', 'has_content'],
});
expect(response.status).toBe(201);
@ -85,7 +87,21 @@ describe('Views Routes', () => {
expect(response.body.filters).toEqual(['Task', 'Project']);
expect(response.body.priority).toBe('high');
expect(response.body.due).toBe('today');
expect(response.body.defer).toBe('tomorrow');
expect(response.body.tags).toEqual(['work', 'important']);
expect(response.body.extras).toEqual(['recurring', 'has_content']);
});
it('should create a view that persists extras without tags', async () => {
const response = await agent.post('/api/views').send({
name: 'Recurring Tasks',
filters: ['Task'],
extras: ['recurring', 'overdue'],
});
expect(response.status).toBe(201);
expect(response.body.extras).toEqual(['recurring', 'overdue']);
expect(response.body.tags).toEqual([]);
});
it('should require view name', async () => {
@ -246,7 +262,9 @@ describe('Views Routes', () => {
filters: ['Task', 'Project'],
priority: 'high',
due: 'today',
defer: 'next_week',
tags: ['work', 'urgent'],
extras: ['recurring'],
is_pinned: true,
});
@ -256,7 +274,9 @@ describe('Views Routes', () => {
expect(response.body.filters).toEqual(['Task', 'Project']);
expect(response.body.priority).toBe('high');
expect(response.body.due).toBe('today');
expect(response.body.defer).toBe('next_week');
expect(response.body.tags).toEqual(['work', 'urgent']);
expect(response.body.extras).toEqual(['recurring']);
expect(response.body.is_pinned).toBe(true);
});
});

View file

@ -2,10 +2,15 @@
set -euo pipefail
# Config
APP_URL_DEFAULT="http://localhost:8080"
BACKEND_URL="http://localhost:3002"
FRONTEND_PORT="${FRONTEND_PORT:-4180}"
FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}"
BACKEND_PORT="${BACKEND_PORT:-3310}"
BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}"
BACKEND_URL="${BACKEND_URL:-http://${BACKEND_HOST}:${BACKEND_PORT}}"
BACKEND_HEALTH="${BACKEND_URL}/api/health"
APP_URL_DEFAULT="http://${FRONTEND_HOST}:${FRONTEND_PORT}"
FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}"
FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-$FRONTEND_URL}"
# Colors
red() { printf "\033[31m%s\033[0m\n" "$*"; }
@ -35,7 +40,8 @@ rm -f backend/db/test.sqlite3
yellow "Starting backend with test database..."
(cd backend && \
NODE_ENV=test \
PORT=3002 \
PORT=$BACKEND_PORT \
HOST=$BACKEND_HOST \
DB_FILE=db/test.sqlite3 \
TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \
@ -51,8 +57,8 @@ cleanup() {
# Kill by known ports (best-effort)
if command -v lsof >/dev/null 2>&1; then
FRONTEND_PIDS_KILL=$(lsof -ti tcp:8080 || true)
BACKEND_PIDS_KILL=$(lsof -ti tcp:3002 || true)
FRONTEND_PIDS_KILL=$(lsof -ti tcp:${FRONTEND_PORT} || true)
BACKEND_PIDS_KILL=$(lsof -ti tcp:${BACKEND_PORT} || true)
if [ -n "${FRONTEND_PIDS_KILL:-}" ]; then kill ${FRONTEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
if [ -n "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
fi
@ -82,6 +88,10 @@ for i in {1..60}; do
done
yellow "Starting frontend dev server..."
BACKEND_URL="$BACKEND_URL" \
FRONTEND_PORT="$FRONTEND_PORT" \
FRONTEND_HOST="$FRONTEND_HOST" \
FRONTEND_ORIGIN="$FRONTEND_ORIGIN" \
npm run frontend:dev >/dev/null 2>&1 &
FRONTEND_PID=$!

View file

@ -14,10 +14,15 @@ TEST_PATTERN="$1"
BROWSER="${2:-Chromium}"
# Config
APP_URL_DEFAULT="http://localhost:8080"
BACKEND_URL="http://localhost:3002"
FRONTEND_PORT="${FRONTEND_PORT:-4180}"
FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}"
BACKEND_PORT="${BACKEND_PORT:-3310}"
BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}"
BACKEND_URL="${BACKEND_URL:-http://${BACKEND_HOST}:${BACKEND_PORT}}"
BACKEND_HEALTH="${BACKEND_URL}/api/health"
APP_URL_DEFAULT="http://${FRONTEND_HOST}:${FRONTEND_PORT}"
FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}"
FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-$FRONTEND_URL}"
# Colors
red() { printf "\033[31m%s\033[0m\n" "$*"; }
@ -51,7 +56,8 @@ rm -f backend/db/test.sqlite3
yellow "Starting backend with test database..."
(cd backend && \
NODE_ENV=test \
PORT=3002 \
PORT=$BACKEND_PORT \
HOST=$BACKEND_HOST \
DB_FILE=db/test.sqlite3 \
TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \
@ -67,8 +73,8 @@ cleanup() {
# Kill by ports (best-effort)
if command -v lsof >/dev/null 2>&1; then
FRONTEND_PIDS_KILL=$(lsof -ti tcp:8080 || true)
BACKEND_PIDS_KILL=$(lsof -ti tcp:3002 || true)
FRONTEND_PIDS_KILL=$(lsof -ti tcp:${FRONTEND_PORT} || true)
BACKEND_PIDS_KILL=$(lsof -ti tcp:${BACKEND_PORT} || true)
if [ -n "${FRONTEND_PIDS_KILL:-}" ]; then kill ${FRONTEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
if [ -n "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
fi
@ -98,6 +104,10 @@ for i in {1..60}; do
done
yellow "Starting frontend dev server..."
BACKEND_URL="$BACKEND_URL" \
FRONTEND_PORT="$FRONTEND_PORT" \
FRONTEND_HOST="$FRONTEND_HOST" \
FRONTEND_ORIGIN="$FRONTEND_ORIGIN" \
npm run frontend:dev >/dev/null 2>&1 &
FRONTEND_PID=$!
@ -122,4 +132,4 @@ yellow "Running Playwright tests matching: ${TEST_PATTERN} on ${BROWSER}..."
APP_URL="$FRONTEND_URL" \
E2E_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
E2E_PASSWORD="${E2E_PASSWORD:-password123}" \
npx playwright test --grep "$TEST_PATTERN" --project="$BROWSER"
npx playwright test --grep "$TEST_PATTERN" --project="$BROWSER"

View file

@ -261,10 +261,7 @@ const App: React.FC = () => {
path="/about"
element={<About isDarkMode={isDarkMode} />}
/>
<Route
path="/backup"
element={<BackupRestore />}
/>
<Route path="/backup" element={<BackupRestore />} />
<Route
path="/admin/users"
element={

View file

@ -97,9 +97,7 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
await loadBackups();
} catch (error) {
console.error('Export error:', error);
showErrorToast(
t('backup.exportError', 'Failed to create backup')
);
showErrorToast(t('backup.exportError', 'Failed to create backup'));
} finally {
setIsExporting(false);
}
@ -164,7 +162,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
try {
await deleteSavedBackup(backupUid);
showSuccessToast(
t('backup.deleteSuccess', 'Backup deleted successfully!')
t(
'backup.deleteSuccess',
'Backup deleted successfully!'
)
);
// Reload the backup list
await loadBackups();
@ -234,9 +235,7 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
}
} catch (error) {
console.error('Import error:', error);
showErrorToast(
t('backup.importError', 'Failed to import backup')
);
showErrorToast(t('backup.importError', 'Failed to import backup'));
} finally {
setIsImporting(false);
}
@ -260,7 +259,9 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
onCancel={() =>
setConfirmDialog({ ...confirmDialog, isOpen: false })
}
/>
)}
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@ -291,7 +292,12 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
>
<div className="flex items-center justify-center space-x-2">
<ArrowDownTrayIcon className="h-5 w-5" />
<span>{t('backup.createBackup', 'Create Backup')}</span>
<span>
{t(
'backup.createBackup',
'Create Backup'
)}
</span>
</div>
</button>
<button
@ -304,7 +310,12 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
>
<div className="flex items-center justify-center space-x-2">
<ArrowUpTrayIcon className="h-5 w-5" />
<span>{t('backup.importFromFile', 'Import from File')}</span>
<span>
{t(
'backup.importFromFile',
'Import from File'
)}
</span>
</div>
</button>
</div>
@ -316,7 +327,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('backup.createNewBackup', 'Create New Backup')}
{t(
'backup.createNewBackup',
'Create New Backup'
)}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t(
@ -353,12 +367,18 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('backup.creating', 'Creating backup...')}
{t(
'backup.creating',
'Creating backup...'
)}
</>
) : (
<>
<ArrowDownTrayIcon className="h-5 w-5 mr-2" />
{t('backup.createBackupNow', 'Create Backup Now')}
{t(
'backup.createBackupNow',
'Create Backup Now'
)}
</>
)}
</button>
@ -367,14 +387,19 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
{t('backup.savedBackups', 'Saved Backups')}
{t(
'backup.savedBackups',
'Saved Backups'
)}
</h3>
<button
onClick={loadBackups}
disabled={isLoadingBackups}
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center"
>
<ArrowPathIcon className={`h-4 w-4 mr-1 ${isLoadingBackups ? 'animate-spin' : ''}`} />
<ArrowPathIcon
className={`h-4 w-4 mr-1 ${isLoadingBackups ? 'animate-spin' : ''}`}
/>
{t('common.refresh', 'Refresh')}
</button>
</div>
@ -385,7 +410,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
</div>
) : savedBackups.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
{t('backup.noBackups', 'No backups found. Create your first backup above.')}
{t(
'backup.noBackups',
'No backups found. Create your first backup above.'
)}
</div>
) : (
<div className="overflow-x-auto">
@ -393,74 +421,136 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t('backup.createdAt', 'Created')}
{t(
'backup.createdAt',
'Created'
)}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t('backup.version', 'Version')}
{t(
'backup.version',
'Version'
)}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t('backup.size', 'Size')}
{t(
'backup.size',
'Size'
)}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t('backup.contents', 'Contents')}
{t(
'backup.contents',
'Contents'
)}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t('backup.actions', 'Actions')}
{t(
'backup.actions',
'Actions'
)}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{savedBackups.map((backup) => (
<tr key={backup.uid} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatDate(backup.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{backup.version}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{formatFileSize(backup.file_size)}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{backup.item_counts.tasks} tasks
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
{backup.item_counts.projects} projects
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
{backup.item_counts.notes} notes
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button
onClick={() => handleRestoreBackup(backup.uid)}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
title={t('backup.restore', 'Restore')}
>
<ArrowPathIcon className="h-5 w-5" />
</button>
<button
onClick={() => handleDownloadBackup(backup.uid)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
title={t('backup.download', 'Download')}
>
<ArrowDownTrayIcon className="h-5 w-5" />
</button>
<button
onClick={() => handleDeleteBackup(backup.uid)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title={t('backup.delete', 'Delete')}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</td>
</tr>
))}
{savedBackups.map(
(backup) => (
<tr
key={backup.uid}
className="hover:bg-gray-50 dark:hover:bg-gray-700"
>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatDate(
backup.created_at
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{
backup.version
}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{formatFileSize(
backup.file_size
)}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{
backup
.item_counts
.tasks
}{' '}
tasks
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
{
backup
.item_counts
.projects
}{' '}
projects
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
{
backup
.item_counts
.notes
}{' '}
notes
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button
onClick={() =>
handleRestoreBackup(
backup.uid
)
}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
title={t(
'backup.restore',
'Restore'
)}
>
<ArrowPathIcon className="h-5 w-5" />
</button>
<button
onClick={() =>
handleDownloadBackup(
backup.uid
)
}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
title={t(
'backup.download',
'Download'
)}
>
<ArrowDownTrayIcon className="h-5 w-5" />
</button>
<button
onClick={() =>
handleDeleteBackup(
backup.uid
)
}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title={t(
'backup.delete',
'Delete'
)}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
@ -471,7 +561,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('backup.importTitle', 'Import from File')}
{t(
'backup.importTitle',
'Import from File'
)}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t(
@ -502,16 +595,24 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
/>
<button
onClick={() => fileInputRef.current?.click()}
onClick={() =>
fileInputRef.current?.click()
}
className="w-full flex items-center justify-center px-6 py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:border-blue-500 dark:hover:border-blue-400 hover:text-blue-600 dark:hover:text-blue-400 transition duration-150 ease-in-out"
>
<div className="text-center">
<ArrowUpTrayIcon className="h-12 w-12 mx-auto mb-2" />
<p className="text-base font-medium">
{t('backup.selectFile', 'Select Backup File')}
{t(
'backup.selectFile',
'Select Backup File'
)}
</p>
<p className="text-sm mt-1">
{t('backup.clickToUpload', 'Click to browse files')}
{t(
'backup.clickToUpload',
'Click to browse files'
)}
</p>
</div>
</button>
@ -524,7 +625,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
{selectedFile.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{(selectedFile.size / 1024).toFixed(2)} KB
{(
selectedFile.size / 1024
).toFixed(2)}{' '}
KB
</p>
</div>
{isValidating && (
@ -554,71 +658,139 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
)}
</div>
{validationResult?.valid && validationResult.summary && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('backup.backupContents', 'Backup contents:')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.tasks} tasks
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.projects} projects
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.notes} notes
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.tags} tags
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.areas} areas
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.views} views
{validationResult?.valid &&
validationResult.summary && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t(
'backup.backupContents',
'Backup contents:'
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{
validationResult
.summary
.tasks
}{' '}
tasks
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{
validationResult
.summary
.projects
}{' '}
projects
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{
validationResult
.summary
.notes
}{' '}
notes
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{
validationResult
.summary
.tags
}{' '}
tags
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{
validationResult
.summary
.areas
}{' '}
areas
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{
validationResult
.summary
.views
}{' '}
views
</div>
</div>
</div>
</div>
)}
)}
{validationResult && !validationResult.valid && (
<div className="mt-4 pt-4 border-t border-red-200 dark:border-red-800">
{validationResult.versionIncompatible ? (
<>
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
{t('backup.versionIncompatible', 'Version Incompatible')}
</p>
<p className="text-sm text-red-600 dark:text-red-400">
{validationResult.message}
</p>
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
{t('backup.backupVersion', 'Backup version')}: {validationResult.backupVersion}
</p>
<p className="text-sm text-red-600 dark:text-red-400">
{t('backup.currentVersion', 'Current version')}: {appVersion}
</p>
</>
) : (
<>
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
{t('backup.validationErrors', 'Validation errors:')}
</p>
<ul className="text-sm text-red-600 dark:text-red-400 space-y-1">
{validationResult.errors?.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
</>
)}
</div>
)}
{validationResult &&
!validationResult.valid && (
<div className="mt-4 pt-4 border-t border-red-200 dark:border-red-800">
{validationResult.versionIncompatible ? (
<>
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
{t(
'backup.versionIncompatible',
'Version Incompatible'
)}
</p>
<p className="text-sm text-red-600 dark:text-red-400">
{
validationResult.message
}
</p>
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
{t(
'backup.backupVersion',
'Backup version'
)}
:{' '}
{
validationResult.backupVersion
}
</p>
<p className="text-sm text-red-600 dark:text-red-400">
{t(
'backup.currentVersion',
'Current version'
)}
: {appVersion}
</p>
</>
) : (
<>
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
{t(
'backup.validationErrors',
'Validation errors:'
)}
</p>
<ul className="text-sm text-red-600 dark:text-red-400 space-y-1">
{validationResult.errors?.map(
(
error,
index
) => (
<li
key={
index
}
>
{' '}
{
error
}
</li>
)
)}
</ul>
</>
)}
</div>
)}
</div>
)}
@ -650,12 +822,18 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('backup.importing', 'Importing...')}
{t(
'backup.importing',
'Importing...'
)}
</>
) : (
<>
<ArrowUpTrayIcon className="h-5 w-5 mr-2" />
{t('backup.restoreBackup', 'Restore Backup')}
{t(
'backup.restoreBackup',
'Restore Backup'
)}
</>
)}
</button>

View file

@ -38,7 +38,10 @@ const Navbar: React.FC<NavbarProps> = ({
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [pomodoroEnabled, setPomodoroEnabled] = useState(true); // Default to true
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>({ backups: false, calendar: false });
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>({
backups: false,
calendar: false,
});
const dropdownRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
@ -264,7 +267,10 @@ const Navbar: React.FC<NavbarProps> = ({
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setIsDropdownOpen(false)}
>
{t('navigation.backupRestore', 'Backup & Restore')}
{t(
'navigation.backupRestore',
'Backup & Restore'
)}
</Link>
)}
{currentUser?.is_admin === true && (

View file

@ -299,7 +299,6 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
}
};
if (totalIssues === 0) {
return null;
}
@ -313,10 +312,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
<ExclamationTriangleIcon className="h-6 w-6 text-yellow-500 dark:text-yellow-400 mr-3" />
<div className="flex-1 text-left">
<p className="text-gray-700 dark:text-gray-300 font-medium">
{t(
'productivity.issuesFound',
{ count: totalIssues }
)}
{t('productivity.issuesFound', { count: totalIssues })}
</p>
<p className="text-yellow-600 dark:text-yellow-400 text-sm">
{t(

View file

@ -98,7 +98,6 @@ const getShareInitials = (value?: string | null) => {
return cleaned.substring(0, 2) || '?';
};
const ProjectItem: React.FC<ProjectItemProps> = ({
project,
viewMode,
@ -221,14 +220,10 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
}
return {
text: t(
'projectItem.overdue',
'Overdue {{count}} {{unit}} ago',
{
count: Math.abs(diff),
unit,
}
),
text: t('projectItem.overdue', 'Overdue {{count}} {{unit}} ago', {
count: Math.abs(diff),
unit,
}),
isOverdue: true,
};
}, [project.due_date_at, t]);
@ -244,9 +239,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
const knownShares = sharedUsers ?? [];
const avatars = knownShares.slice(0, MAX_SHARE_AVATARS);
const totalCount =
(sharedUsers?.length ??
project.share_count ??
avatars.length) || 0;
(sharedUsers?.length ?? project.share_count ?? avatars.length) || 0;
const remaining = Math.max(0, totalCount - avatars.length);
return { avatars, remaining };
@ -313,7 +306,10 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
return (
<StateIcon
className="h-4 w-4 text-white/80 drop-shadow-sm"
title={getStateLabel(project.state, t)}
title={getStateLabel(
project.state,
t
)}
/>
);
})()}
@ -353,10 +349,14 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
'Permission denied'
)
);
setActiveDropdown(null);
setActiveDropdown(
null
);
return;
}
handleEditProject(project);
handleEditProject(
project
);
setActiveDropdown(null);
}}
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
@ -369,8 +369,12 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenShare(project);
setActiveDropdown(null);
onOpenShare(
project
);
setActiveDropdown(
null
);
}}
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
>
@ -395,8 +399,12 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
);
return;
}
setProjectToDelete(project);
setIsConfirmDialogOpen(true);
setProjectToDelete(
project
);
setIsConfirmDialogOpen(
true
);
setActiveDropdown(null);
}}
className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
@ -453,9 +461,13 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
title={
(project as any).task_status
? `${(project as any).task_status.done} of ${(project as any).task_status.total} tasks completed (${getCompletionPercentage()}%)`
: t('projectItem.completionPercentage', {
percentage: getCompletionPercentage(),
})
: t(
'projectItem.completionPercentage',
{
percentage:
getCompletionPercentage(),
}
)
}
>
<div
@ -471,46 +483,61 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
: '0/0'}
</span>
</div>
<div className="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-400">
<div className="flex items-center min-w-0">
<div className="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-400">
<div className="flex items-center min-w-0">
{dueInfo.isOverdue ? (
<span className="inline-flex items-center space-x-1 rounded-full bg-red-100 px-2 py-0.5 text-red-700 dark:bg-red-900/40 dark:text-red-300 font-semibold text-[11px] leading-snug">
<ExclamationTriangleIcon className="h-3 w-3 flex-shrink-0" style={{ marginTop: '1px' }} />
<ExclamationTriangleIcon
className="h-3 w-3 flex-shrink-0"
style={{ marginTop: '1px' }}
/>
<span>{dueInfo.text}</span>
</span>
) : (
<span className="truncate">{dueInfo.text}</span>
<span className="truncate">
{dueInfo.text}
</span>
)}
</div>
<div className="flex items-center justify-end min-w-0 h-7">
{project.is_shared && (
<div className="flex items-center -space-x-2 h-full">
<>
{shareAvatars.avatars.map((share) => (
<Tooltip
key={`${project.uid}-${share.user_id}`}
content={
share.email
? getShareDisplayName(share.email)
: t(
'projectItem.sharedUser',
'Shared user'
)
}
>
{share.avatar_image ? (
<img
src={getApiPath(share.avatar_image)}
alt={getShareDisplayName(share.email)}
className="h-7 w-7 rounded-full border-2 border-white object-cover shadow-sm dark:border-gray-900"
/>
) : (
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-gradient-to-br from-blue-500 to-purple-500 text-xs font-semibold text-white shadow-sm dark:border-gray-900">
{getShareInitials(share.email)}
</span>
)}
</Tooltip>
))}
{shareAvatars.avatars.map(
(share) => (
<Tooltip
key={`${project.uid}-${share.user_id}`}
content={
share.email
? getShareDisplayName(
share.email
)
: t(
'projectItem.sharedUser',
'Shared user'
)
}
>
{share.avatar_image ? (
<img
src={getApiPath(
share.avatar_image
)}
alt={getShareDisplayName(
share.email
)}
className="h-7 w-7 rounded-full border-2 border-white object-cover shadow-sm dark:border-gray-900"
/>
) : (
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-gradient-to-br from-blue-500 to-purple-500 text-xs font-semibold text-white shadow-sm dark:border-gray-900">
{getShareInitials(
share.email
)}
</span>
)}
</Tooltip>
)
)}
{shareAvatars.remaining > 0 && (
<Tooltip
content={t(
@ -522,7 +549,8 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
)}
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-gray-200 text-xs font-semibold text-gray-700 shadow-sm dark:border-gray-900 dark:bg-gray-700 dark:text-gray-200">
+{shareAvatars.remaining}
+
{shareAvatars.remaining}
</span>
</Tooltip>
)}

View file

@ -27,7 +27,10 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
}) => {
const { t } = useTranslation();
const store = useStore();
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>({ backups: false, calendar: false });
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>({
backups: false,
calendar: false,
});
const inboxItemsCount = store.inboxStore.pagination.total;
@ -72,7 +75,7 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
},
];
const navLinks = allNavLinks.filter(link => {
const navLinks = allNavLinks.filter((link) => {
if (link.featureFlag) {
return featureFlags[link.featureFlag as keyof FeatureFlags];
}

View file

@ -1248,6 +1248,7 @@ const TaskDetails: React.FC = () => {
onToggleTodayPlan={handleToggleTodayPlan}
onQuickStatusToggle={handleQuickStatusToggle}
attachmentCount={attachmentCount}
subtasksCount={subtasks.length}
/>
{/* Content - Full width layout */}
@ -1285,49 +1286,47 @@ const TaskDetails: React.FC = () => {
onLoadTags={() => tagsStore.loadTags()}
getTagLink={getTagLink}
/>
<TaskDueDateCard
task={task}
isEditing={isEditingDueDate}
editedDueDate={editedDueDate}
onChangeDate={setEditedDueDate}
onStartEdit={handleStartDueDateEdit}
onSave={handleSaveDueDate}
onCancel={handleCancelDueDateEdit}
/>
<TaskDeferUntilCard
task={task}
isEditing={isEditingDeferUntil}
editedDeferUntil={editedDeferUntil}
onChangeDateTime={setEditedDeferUntil}
onStartEdit={handleStartDeferUntilEdit}
onSave={handleSaveDeferUntil}
onCancel={handleCancelDeferUntilEdit}
/>
</div>
</div>
)}
{/* Schedule Pill */}
{activePill === 'schedule' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<TaskDueDateCard
{/* Recurrence Pill */}
{activePill === 'recurrence' && (
<div className="grid grid-cols-1">
<TaskRecurrenceCard
task={task}
isEditing={isEditingDueDate}
editedDueDate={editedDueDate}
onChangeDate={setEditedDueDate}
onStartEdit={handleStartDueDateEdit}
onSave={handleSaveDueDate}
onCancel={handleCancelDueDateEdit}
parentTask={parentTask}
loadingParent={loadingParent}
isEditing={isEditingRecurrence}
recurrenceForm={recurrenceForm}
onStartEdit={handleStartRecurrenceEdit}
onChange={handleRecurrenceChange}
onSave={handleSaveRecurrence}
onCancel={handleCancelRecurrenceEdit}
loadingIterations={loadingIterations}
nextIterations={nextIterations}
canEdit={!task.recurring_parent_id}
/>
<TaskDeferUntilCard
task={task}
isEditing={isEditingDeferUntil}
editedDeferUntil={editedDeferUntil}
onChangeDateTime={setEditedDeferUntil}
onStartEdit={handleStartDeferUntilEdit}
onSave={handleSaveDeferUntil}
onCancel={handleCancelDeferUntilEdit}
/>
<div className="md:col-span-2">
<TaskRecurrenceCard
task={task}
parentTask={parentTask}
loadingParent={loadingParent}
isEditing={isEditingRecurrence}
recurrenceForm={recurrenceForm}
onStartEdit={handleStartRecurrenceEdit}
onChange={handleRecurrenceChange}
onSave={handleSaveRecurrence}
onCancel={handleCancelRecurrenceEdit}
loadingIterations={loadingIterations}
nextIterations={nextIterations}
canEdit={!task.recurring_parent_id}
/>
</div>
</div>
)}
@ -1362,16 +1361,11 @@ const TaskDetails: React.FC = () => {
{/* Activity Pill */}
{activePill === 'activity' && (
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('task.recentActivity', 'Recent Activity')}
</h4>
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 p-6">
<TaskTimeline
taskUid={task.uid}
refreshKey={timelineRefreshKey}
/>
</div>
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 p-6">
<TaskTimeline
taskUid={task.uid}
refreshKey={timelineRefreshKey}
/>
</div>
)}
</div>

View file

@ -40,6 +40,7 @@ interface TaskDetailsHeaderProps {
onToggleTodayPlan?: () => void;
onQuickStatusToggle?: () => void;
attachmentCount?: number;
subtasksCount?: number;
}
const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
@ -61,6 +62,7 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
onToggleTodayPlan,
onQuickStatusToggle,
attachmentCount = 0,
subtasksCount = 0,
}) => {
const { t } = useTranslation();
const [isEditingTitle, setIsEditingTitle] = useState(false);
@ -657,7 +659,7 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
<span className="text-xs text-gray-400 dark:text-gray-500 sm:pl-1 mt-1 sm:mt-0">
{t(
'task.lastUpdatedAt',
'Last updated at'
'Updated at'
)}
:{' '}
<span className="text-gray-500 dark:text-gray-400">
@ -761,23 +763,30 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
</button>
<button
onClick={() => onPillChange('subtasks')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors relative ${
activePill === 'subtasks'
? 'bg-blue-500 dark:bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{t('task.subtasks', 'Subtasks')}
{subtasksCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-blue-500 dark:bg-blue-400 rounded-full border border-white dark:border-gray-900"></span>
)}
</button>
<button
onClick={() => onPillChange('schedule')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
activePill === 'schedule'
onClick={() => onPillChange('recurrence')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors relative ${
activePill === 'recurrence'
? 'bg-blue-500 dark:bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{t('task.schedule', 'Schedule')}
{t('task.recurrence', 'Recurrence')}
{task.recurrence_type &&
task.recurrence_type !== 'none' && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-blue-500 dark:bg-blue-400 rounded-full border border-white dark:border-gray-900"></span>
)}
</button>
<button
onClick={() => onPillChange('attachments')}

View file

@ -113,9 +113,6 @@ const TaskRecurrenceCard: React.FC<TaskRecurrenceCardProps> = ({
return (
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('task.recurringSetup', 'Recurring Setup')}
</h4>
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 space-y-4 ${
canEdit && !isEditing ? 'cursor-pointer' : ''
@ -239,7 +236,7 @@ const TaskRecurrenceCard: React.FC<TaskRecurrenceCardProps> = ({
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
{t(
'task.notRecurring',
'This task is not recurring yet.'
'Add recurrence details'
)}
</div>
)}

View file

@ -62,10 +62,16 @@ const deferOptions = [
{ value: 'next_month', labelKey: 'dateIndicators.nextMonth' },
];
const recurringOptions = [
{ value: 'recurring', labelKey: 'search.recurringFilter.recurring' },
{ value: 'non_recurring', labelKey: 'search.recurringFilter.nonRecurring' },
{ value: 'instances', labelKey: 'search.recurringFilter.instances' },
const extrasOptions = [
{ value: 'recurring', labelKey: 'search.extrasFilter.isRecurring' },
{ value: 'overdue', labelKey: 'search.extrasFilter.isOverdue' },
{ value: 'has_content', labelKey: 'search.extrasFilter.hasContent' },
{ value: 'deferred', labelKey: 'search.extrasFilter.isDeferred' },
{ value: 'has_tags', labelKey: 'search.extrasFilter.hasTags' },
{
value: 'assigned_to_project',
labelKey: 'search.extrasFilter.isAssignedToProject',
},
];
const SearchMenu: React.FC<SearchMenuProps> = ({
@ -82,9 +88,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
const [selectedDue, setSelectedDue] = useState<string | null>(null);
const [selectedDefer, setSelectedDefer] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedRecurring, setSelectedRecurring] = useState<string | null>(
null
);
const [selectedExtras, setSelectedExtras] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<
Array<{ id: number; name: string }>
>([]);
@ -132,9 +136,11 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
);
};
const handleRecurringToggle = (recurring: string) => {
setSelectedRecurring(
selectedRecurring === recurring ? null : recurring
const handleExtrasToggle = (extra: string) => {
setSelectedExtras((prev) =>
prev.includes(extra)
? prev.filter((e) => e !== extra)
: [...prev, extra]
);
};
@ -162,7 +168,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
due: selectedDue || null,
defer: selectedDefer || null,
tags: selectedTags.length > 0 ? selectedTags : null,
recurring: selectedRecurring || null,
extras: selectedExtras.length > 0 ? selectedExtras : null,
}),
});
@ -350,25 +356,45 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
parts.push(...tagsWithSeparators);
}
// Add recurring filter
if (selectedRecurring) {
const recurringOption = recurringOptions.find(
(opt) => opt.value === selectedRecurring
);
const recurringLabel = recurringOption
? t(recurringOption.labelKey)
: selectedRecurring;
// Add extras filters
if (selectedExtras.length > 0) {
parts.push(
<span key="recurring-label">{t('search.thatAre') + ' '}</span>
);
parts.push(
<span
key="recurring"
style={{ fontWeight: 800, fontStyle: 'normal' }}
>
{recurringLabel}
</span>
<span key="extras-label">{t('search.thatAre') + ' '}</span>
);
const extrasElements = selectedExtras.map((extra) => {
const extraOption = extrasOptions.find(
(opt) => opt.value === extra
);
const extraLabel = extraOption
? t(extraOption.labelKey)
: extra;
return (
<span
key={`extra-${extra}`}
style={{ fontWeight: 800, fontStyle: 'normal' }}
>
{extraLabel}
</span>
);
});
const extrasWithSeparators: React.ReactNode[] = [];
extrasElements.forEach((extraEl, index) => {
if (index > 0) {
if (index === extrasElements.length - 1) {
extrasWithSeparators.push(
<span key={`sep-extra-and-${index}`}>
{' ' + t('search.and') + ' '}
</span>
);
} else {
extrasWithSeparators.push(
<span key={`sep-extra-comma-${index}`}>{', '}</span>
);
}
}
extrasWithSeparators.push(extraEl);
});
parts.push(...extrasWithSeparators);
}
if (parts.length === 0) return null;
@ -389,7 +415,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
selectedDue ||
selectedDefer ||
selectedTags.length > 0 ||
selectedRecurring;
selectedExtras.length > 0;
return (
<div
@ -567,29 +593,21 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
{t('search.extras')}
</div>
{/* Recurring Filters */}
<div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
{t('search.recurringFilter.label')}
</div>
<div className="flex flex-wrap gap-2">
{recurringOptions.map((option) => (
<FilterBadge
key={option.value}
name={t(option.labelKey)}
color="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
isSelected={
selectedRecurring ===
option.value
}
onToggle={() =>
handleRecurringToggle(
option.value
)
}
/>
))}
</div>
{/* Extras Filters */}
<div className="flex flex-wrap gap-2">
{extrasOptions.map((option) => (
<FilterBadge
key={option.value}
name={t(option.labelKey)}
color="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
isSelected={selectedExtras.includes(
option.value
)}
onToggle={() =>
handleExtrasToggle(option.value)
}
/>
))}
</div>
</div>
@ -683,7 +701,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
selectedDue={selectedDue}
selectedDefer={selectedDefer}
selectedTags={selectedTags}
selectedRecurring={selectedRecurring}
selectedExtras={selectedExtras}
onClose={onClose}
/>
</div>

View file

@ -17,7 +17,7 @@ interface SearchResultsProps {
selectedDue: string | null;
selectedDefer: string | null;
selectedTags: string[];
selectedRecurring: string | null;
selectedExtras: string[];
onClose: () => void;
}
@ -39,7 +39,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
selectedDue,
selectedDefer,
selectedTags,
selectedRecurring,
selectedExtras,
onClose,
}) => {
const { t } = useTranslation();
@ -56,7 +56,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
!selectedDue &&
!selectedDefer &&
selectedTags.length === 0 &&
!selectedRecurring
selectedExtras.length === 0
) {
setResults([]);
return;
@ -71,7 +71,8 @@ const SearchResults: React.FC<SearchResultsProps> = ({
due: selectedDue || undefined,
defer: selectedDefer || undefined,
tags: selectedTags.length > 0 ? selectedTags : undefined,
recurring: selectedRecurring || undefined,
extras:
selectedExtras.length > 0 ? selectedExtras : undefined,
});
setResults(data.results);
} catch (error) {
@ -91,7 +92,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
selectedDue,
selectedDefer,
selectedTags,
selectedRecurring,
selectedExtras,
]);
const getIcon = (type: string) => {

View file

@ -31,8 +31,9 @@ interface View {
filters: string[];
priority: string | null;
due: string | null;
defer: string | null;
tags: string[];
recurring: string | null;
extras: string[] | null;
is_pinned: boolean;
}
@ -228,21 +229,31 @@ const ViewDetail: React.FC = () => {
return;
}
const viewData = await viewResponse.json();
setView(viewData);
const normalizedView: View = {
...viewData,
tags: viewData.tags || [],
extras: viewData.extras || [],
defer: viewData.defer || null,
};
setView(normalizedView);
const currentOffset = resetPagination ? 0 : offset;
// Fetch search results with pagination and exclude subtasks
const response = await searchUniversal({
query: viewData.search_query || '',
filters: viewData.filters,
priority: viewData.priority || undefined,
due: viewData.due || undefined,
query: normalizedView.search_query || '',
filters: normalizedView.filters,
priority: normalizedView.priority || undefined,
due: normalizedView.due || undefined,
defer: normalizedView.defer || undefined,
tags:
viewData.tags && viewData.tags.length > 0
? viewData.tags
normalizedView.tags && normalizedView.tags.length > 0
? normalizedView.tags
: undefined,
extras:
normalizedView.extras && normalizedView.extras.length > 0
? normalizedView.extras
: undefined,
recurring: viewData.recurring || undefined,
limit: limit,
offset: currentOffset,
excludeSubtasks: true,
@ -742,6 +753,19 @@ const ViewDetail: React.FC = () => {
</span>
</div>
)}
{view.defer && (
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
{t('search.deferUntil')}
</p>
<span className="px-2 py-1 bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200 rounded text-xs font-medium capitalize">
{view.defer.replace(
/_/g,
' '
)}
</span>
</div>
)}
{view.tags &&
view.tags.length > 0 && (
<div>
@ -764,26 +788,43 @@ const ViewDetail: React.FC = () => {
</div>
</div>
)}
{view.recurring && (
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
{t('views.recurring')}
</p>
<span className="px-2 py-1 bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded text-xs font-medium capitalize">
{view.recurring.replace(
/_/g,
' '
)}
</span>
</div>
)}
{view.extras &&
view.extras.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
{t('search.extras')}
</p>
<div className="flex flex-wrap gap-1.5">
{view.extras.map(
(
extra,
index
) => (
<span
key={
index
}
className="px-2 py-1 bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded text-xs font-medium capitalize"
>
{extra.replace(
/_/g,
' '
)}
</span>
)
)}
</div>
</div>
)}
{!view.filters.length &&
!view.search_query &&
!view.priority &&
!view.due &&
(!view.tags ||
view.tags.length === 0) &&
!view.recurring && (
(!view.extras ||
view.extras.length ===
0) && (
<p className="text-sm text-gray-600 dark:text-gray-400 italic">
{t(
'views.noCriteriaSet'

View file

@ -18,6 +18,9 @@ interface View {
filters: string[];
priority: string | null;
due: string | null;
defer: string | null;
tags: string[];
extras: string[] | null;
is_pinned: boolean;
}
@ -43,7 +46,13 @@ const Views: React.FC = () => {
});
if (response.ok) {
const data = await response.json();
setViews(data);
const normalized: View[] = data.map((view: View) => ({
...view,
tags: view.tags || [],
extras: view.extras || [],
defer: view.defer || null,
}));
setViews(normalized);
}
} catch (error) {
console.error('Error fetching views:', error);
@ -236,6 +245,24 @@ const Views: React.FC = () => {
{view.due}
</p>
)}
{view.defer && (
<p>
{' '}
{t('search.deferUntil')}{' '}
{view.defer}
</p>
)}
{view.extras &&
view.extras.length > 0 && (
<p>
{' '}
{t('search.extras')}
:{' '}
{view.extras.join(
', '
)}
</p>
)}
</div>
</div>

View file

@ -222,9 +222,7 @@ export const importBackup = async (
/**
* Validate backup file without importing
*/
export const validateBackup = async (
file: File
): Promise<ValidationResult> => {
export const validateBackup = async (file: File): Promise<ValidationResult> => {
const formData = new FormData();
formData.append('backup', file);

View file

@ -7,7 +7,7 @@ interface SearchParams {
due?: string;
defer?: string;
tags?: string[];
recurring?: string;
extras?: string[];
limit?: number;
offset?: number;
excludeSubtasks?: boolean;
@ -66,8 +66,8 @@ export const searchUniversal = async (
queryParams.append('tags', params.tags.join(','));
}
if (params.recurring) {
queryParams.append('recurring', params.recurring);
if (params.extras && params.extras.length > 0) {
queryParams.append('extras', params.extras.join(','));
}
if (params.limit !== undefined) {

View file

@ -745,7 +745,7 @@
"dueDate": "تاريخ الاستحقاق",
"deferUntil": "تأجيل حتى",
"recurringSetup": "إعداد متكرر",
"notRecurring": "هذه المهمة ليست متكررة بعد.",
"notRecurring": "أضف تفاصيل التكرار",
"clickToEditTitle": "انقر لتحرير العنوان",
"clickToEditContent": "انقر لتحرير المحتوى",
"clickToAddContent": "انقر لإضافة محتوى",
@ -1206,7 +1206,15 @@
"instances": "حالات متكررة"
},
"deferUntilFilter": "تأجيل حتى",
"deferUntil": "، تأجيل حتى"
"deferUntil": "، تأجيل حتى",
"extrasFilter": {
"isRecurring": "يتكرر",
"isOverdue": "متأخر",
"hasContent": "يمتلك محتوى",
"isDeferred": "مؤجل",
"hasTags": "يمتلك علامات",
"isAssignedToProject": "مخصص لمشروع"
}
},
"subtasks": {
"placeholder": "أضف مهمة فرعية..."

View file

@ -1206,7 +1206,15 @@
"instances": "повтарящи се инстанции"
},
"deferUntilFilter": "Отложи до",
"deferUntil": ", отложи до"
"deferUntil": ", отложи до",
"extrasFilter": {
"isRecurring": "е Повтарящо се",
"isOverdue": "е Просрочено",
"hasContent": "има Съдържание",
"isDeferred": "е Отложено",
"hasTags": "има Тагове",
"isAssignedToProject": "е Назначено на Проект"
}
},
"subtasks": {
"placeholder": "Добавете подзадача..."

View file

@ -1206,7 +1206,15 @@
"instances": "gentagende instanser"
},
"deferUntilFilter": "Udskyd indtil",
"deferUntil": ", udskyd indtil"
"deferUntil": ", udskyd indtil",
"extrasFilter": {
"isRecurring": "er tilbagevendende",
"isOverdue": "er forfalden",
"hasContent": "har indhold",
"isDeferred": "er udsat",
"hasTags": "har tags",
"isAssignedToProject": "er tildelt projekt"
}
},
"subtasks": {
"placeholder": "Tilføj en underopgave..."

View file

@ -1215,7 +1215,15 @@
"instances": "wiederkehrende Instanzen"
},
"deferUntilFilter": "Bis zu",
"deferUntil": ", bis zu"
"deferUntil": ", bis zu",
"extrasFilter": {
"isRecurring": "ist wiederkehrend",
"isOverdue": "ist überfällig",
"hasContent": "hat Inhalt",
"isDeferred": "ist verschoben",
"hasTags": "hat Tags",
"isAssignedToProject": "ist einem Projekt zugewiesen"
}
},
"subtasks": {
"placeholder": "Fügen Sie eine Unteraufgabe hinzu..."

View file

@ -1210,7 +1210,15 @@
"instances": "επαναλαμβανόμενες περιπτώσεις"
},
"deferUntilFilter": "Αναβολή μέχρι",
"deferUntil": ", αναβολή μέχρι"
"deferUntil": ", αναβολή μέχρι",
"extrasFilter": {
"isRecurring": "είναι Επαναλαμβανόμενο",
"isOverdue": "είναι Υπερβολικό",
"hasContent": "έχει Περιεχόμενο",
"isDeferred": "είναι Αναβληθέν",
"hasTags": "έχει Ετικέτες",
"isAssignedToProject": "είναι Ανατεθειμένο σε Έργο"
}
},
"subtasks": {
"placeholder": "Προσθέστε μια υποεργασία..."

View file

@ -723,7 +723,7 @@
"dueDate": "Due Date",
"noDueDate": "No due date",
"recurringSetup": "Recurring Setup",
"notRecurring": "This task is not recurring yet.",
"notRecurring": "Add recurrence details",
"instanceOf": "This is an instance of a recurring task",
"parentTask": "Parent Task",
"clickToEditTitle": "Click to edit title",
@ -1179,11 +1179,13 @@
"deferUntilFilter": "Defer Until",
"deferUntil": ", defer until",
"tagsFilter": "Tags",
"recurringFilter": {
"label": "Recurring",
"recurring": "recurring templates",
"nonRecurring": "non-recurring",
"instances": "recurring instances"
"extrasFilter": {
"isRecurring": "is Recurring",
"isOverdue": "is Overdue",
"hasContent": "has Content",
"isDeferred": "is Deferred",
"hasTags": "has Tags",
"isAssignedToProject": "is Assigned to Project"
},
"saveAsSmartView": "Save as Smart View",
"viewName": "View Name",

View file

@ -1207,7 +1207,15 @@
"instances": "instancias recurrentes"
},
"deferUntilFilter": "Aplazar hasta",
"deferUntil": ", aplazar hasta"
"deferUntil": ", aplazar hasta",
"extrasFilter": {
"isRecurring": "es Recurrente",
"isOverdue": "está Vencido",
"hasContent": "tiene Contenido",
"isDeferred": "está Diferido",
"hasTags": "tiene Etiquetas",
"isAssignedToProject": "está Asignado al Proyecto"
}
},
"subtasks": {
"placeholder": "Agregar una subtarea..."

View file

@ -1206,7 +1206,15 @@
"instances": "toistuvat instanssit"
},
"deferUntilFilter": "Viivästytä kunnes",
"deferUntil": ", viivästytä kunnes"
"deferUntil": ", viivästytä kunnes",
"extrasFilter": {
"isRecurring": "on Toistuva",
"isOverdue": "on Erääntynyt",
"hasContent": "on Sisältöä",
"isDeferred": "on Siirretty",
"hasTags": "on Tunnisteita",
"isAssignedToProject": "on Määrätty Projektiin"
}
},
"subtasks": {
"placeholder": "Lisää alitehtävä..."

View file

@ -1206,7 +1206,15 @@
"instances": "instances récurrentes"
},
"deferUntilFilter": "Différer jusqu'à",
"deferUntil": ", différer jusqu'à"
"deferUntil": ", différer jusqu'à",
"extrasFilter": {
"isRecurring": "est Récurrent",
"isOverdue": "est En Retard",
"hasContent": "a du Contenu",
"isDeferred": "est Différé",
"hasTags": "a des Étiquettes",
"isAssignedToProject": "est Assigné au Projet"
}
},
"subtasks": {
"placeholder": "Ajouter une sous-tâche..."

View file

@ -1206,7 +1206,15 @@
"instances": "instansi berulang"
},
"deferUntilFilter": "Tunda Hingga",
"deferUntil": ", tunda hingga"
"deferUntil": ", tunda hingga",
"extrasFilter": {
"isRecurring": "adalah Berulang",
"isOverdue": "adalah Terlambat",
"hasContent": "memiliki Konten",
"isDeferred": "adalah Ditunda",
"hasTags": "memiliki Tag",
"isAssignedToProject": "adalah Ditugaskan ke Proyek"
}
},
"subtasks": {
"placeholder": "Tambahkan subtugas..."

View file

@ -1206,7 +1206,15 @@
"instances": "istanze ricorrenti"
},
"deferUntilFilter": "Rimanda fino a",
"deferUntil": ", rimanda fino a"
"deferUntil": ", rimanda fino a",
"extrasFilter": {
"isRecurring": "è Ricorrente",
"isOverdue": "è Scaduto",
"hasContent": "ha Contenuto",
"isDeferred": "è Rinviato",
"hasTags": "ha Tag",
"isAssignedToProject": "è Assegnato al Progetto"
}
},
"subtasks": {
"placeholder": "Aggiungi un sottocompito..."

View file

@ -1206,7 +1206,15 @@
"instances": "定期的なインスタンス"
},
"deferUntilFilter": "フィルターまで遅延",
"deferUntil": "、遅延するまで"
"deferUntil": "、遅延するまで",
"extrasFilter": {
"isRecurring": "繰り返し",
"isOverdue": "期限切れ",
"hasContent": "コンテンツあり",
"isDeferred": "保留中",
"hasTags": "タグあり",
"isAssignedToProject": "プロジェクトに割り当てられている"
}
},
"subtasks": {
"placeholder": "サブタスクを追加..."

View file

@ -1206,7 +1206,15 @@
"instances": "반복 인스턴스"
},
"deferUntilFilter": "지연할 때까지",
"deferUntil": ", 지연할 때까지"
"deferUntil": ", 지연할 때까지",
"extrasFilter": {
"isRecurring": "반복됨",
"isOverdue": "연체됨",
"hasContent": "내용이 있음",
"isDeferred": "연기됨",
"hasTags": "태그가 있음",
"isAssignedToProject": "프로젝트에 할당됨"
}
},
"subtasks": {
"placeholder": "하위 작업 추가..."

View file

@ -1206,7 +1206,15 @@
"instances": "herhalende instanties"
},
"deferUntilFilter": "Uitstellen tot",
"deferUntil": ", uitstellen tot"
"deferUntil": ", uitstellen tot",
"extrasFilter": {
"isRecurring": "is Herhalend",
"isOverdue": "is Achterstallig",
"hasContent": "heeft Inhoud",
"isDeferred": "is Uitgesteld",
"hasTags": "heeft Tags",
"isAssignedToProject": "is Toegewezen aan Project"
}
},
"subtasks": {
"placeholder": "Voeg een subtaak toe..."

View file

@ -1206,7 +1206,15 @@
"instances": "gjentakende instanser"
},
"deferUntilFilter": "Utsett til",
"deferUntil": ", utsett til"
"deferUntil": ", utsett til",
"extrasFilter": {
"isRecurring": "er Gjentakende",
"isOverdue": "er Forfalt",
"hasContent": "har Innhold",
"isDeferred": "er Utsatt",
"hasTags": "har Tagger",
"isAssignedToProject": "er Tildelt Prosjekt"
}
},
"subtasks": {
"placeholder": "Legg til en underoppgave..."

View file

@ -1206,7 +1206,15 @@
"instances": "powtarzające się instancje"
},
"deferUntilFilter": "Odłóż do",
"deferUntil": ", odłóż do"
"deferUntil": ", odłóż do",
"extrasFilter": {
"isRecurring": "jest powtarzający się",
"isOverdue": "jest przeterminowany",
"hasContent": "ma zawartość",
"isDeferred": "jest odroczony",
"hasTags": "ma tagi",
"isAssignedToProject": "jest przypisany do projektu"
}
},
"subtasks": {
"placeholder": "Dodaj podzadanie..."

View file

@ -1206,7 +1206,15 @@
"instances": "instâncias recorrentes"
},
"deferUntilFilter": "Aguardar Até",
"deferUntil": ", aguardar até"
"deferUntil": ", aguardar até",
"extrasFilter": {
"isRecurring": "é Recorrente",
"isOverdue": "está Atrasado",
"hasContent": "tem Conteúdo",
"isDeferred": "está Adiado",
"hasTags": "tem Tags",
"isAssignedToProject": "está Atribuído ao Projeto"
}
},
"subtasks": {
"placeholder": "Adicionar uma subtarefa..."

View file

@ -1206,7 +1206,15 @@
"instances": "instanțe recurente"
},
"deferUntilFilter": "Amână până la",
"deferUntil": ", amână până la"
"deferUntil": ", amână până la",
"extrasFilter": {
"isRecurring": "este Recurent",
"isOverdue": "este Întârziat",
"hasContent": "are Conținut",
"isDeferred": "este Amânat",
"hasTags": "are Etichete",
"isAssignedToProject": "este Atribuit Proiectului"
}
},
"subtasks": {
"placeholder": "Adaugă o subtask..."

View file

@ -1206,7 +1206,15 @@
"instances": "повторяющиеся экземпляры"
},
"deferUntilFilter": "Отложить до",
"deferUntil": ", отложить до"
"deferUntil": ", отложить до",
"extrasFilter": {
"isRecurring": "является повторяющимся",
"isOverdue": "просрочено",
"hasContent": "имеет содержимое",
"isDeferred": "отложено",
"hasTags": "имеет теги",
"isAssignedToProject": "назначено на проект"
}
},
"subtasks": {
"placeholder": "Добавить подзадачу..."

View file

@ -1206,7 +1206,15 @@
"instances": "ponavljajoče se instance"
},
"deferUntilFilter": "Odloži do",
"deferUntil": ", odloži do"
"deferUntil": ", odloži do",
"extrasFilter": {
"isRecurring": "je ponavljajoče",
"isOverdue": "je zapadlo",
"hasContent": "ima vsebino",
"isDeferred": "je odloženo",
"hasTags": "ima oznake",
"isAssignedToProject": "je dodeljeno projektu"
}
},
"subtasks": {
"placeholder": "Dodaj podnalogo..."

View file

@ -1206,7 +1206,15 @@
"instances": "återkommande instanser"
},
"deferUntilFilter": "Skjut upp tills",
"deferUntil": ", skjuta upp tills"
"deferUntil": ", skjuta upp tills",
"extrasFilter": {
"isRecurring": "är Återkommande",
"isOverdue": "är Försenad",
"hasContent": "har Innehåll",
"isDeferred": "är Utsatt",
"hasTags": "har Taggar",
"isAssignedToProject": "är Tilldelad Projekt"
}
},
"subtasks": {
"placeholder": "Lägg till en deluppgift..."

View file

@ -1206,7 +1206,15 @@
"instances": "tekrarlayan örnekler"
},
"deferUntilFilter": "Ertele",
"deferUntil": ", ertele"
"deferUntil": ", ertele",
"extrasFilter": {
"isRecurring": "Tekrarlayan",
"isOverdue": "Vadesi Geçmiş",
"hasContent": "İçerik Var",
"isDeferred": "Ertelenmiş",
"hasTags": "Etiket Var",
"isAssignedToProject": "Projeye Atanmış"
}
},
"subtasks": {
"placeholder": "Bir alt görev ekle..."

View file

@ -1206,7 +1206,15 @@
"instances": "повторювані екземпляри"
},
"deferUntilFilter": "Відкласти до",
"deferUntil": ", відкласти до"
"deferUntil": ", відкласти до",
"extrasFilter": {
"isRecurring": "є повторюваним",
"isOverdue": "прострочено",
"hasContent": "має вміст",
"isDeferred": "відкладено",
"hasTags": "має теги",
"isAssignedToProject": "призначено проекту"
}
},
"subtasks": {
"placeholder": "Додати підзадачу..."

View file

@ -1206,7 +1206,15 @@
"instances": "các phiên bản lặp lại"
},
"deferUntilFilter": "Hoãn lại cho đến",
"deferUntil": ", hoãn lại cho đến"
"deferUntil": ", hoãn lại cho đến",
"extrasFilter": {
"isRecurring": "là Lặp lại",
"isOverdue": "đã Quá hạn",
"hasContent": "có Nội dung",
"isDeferred": "đã Hoãn lại",
"hasTags": "có Thẻ",
"isAssignedToProject": "được Giao cho Dự án"
}
},
"subtasks": {
"placeholder": "Thêm một công việc phụ..."

View file

@ -1206,7 +1206,15 @@
"instances": "循环实例"
},
"deferUntilFilter": "延迟到",
"deferUntil": ",延迟到"
"deferUntil": ",延迟到",
"extrasFilter": {
"isRecurring": "是重复的",
"isOverdue": "已逾期",
"hasContent": "有内容",
"isDeferred": "已延迟",
"hasTags": "有标签",
"isAssignedToProject": "已分配给项目"
}
},
"subtasks": {
"placeholder": "添加子任务..."

View file

@ -5,6 +5,14 @@ const CopyWebpackPlugin = require('copy-webpack-plugin');
const webpack = require('webpack');
const isDevelopment = process.env.NODE_ENV !== 'production';
const frontendPort = parseInt(process.env.FRONTEND_PORT || '8080', 10);
const frontendHost = process.env.FRONTEND_HOST || '0.0.0.0';
const backendUrl = process.env.BACKEND_URL || 'http://localhost:3002';
const frontendUrl = new URL(
process.env.FRONTEND_ORIGIN || `http://localhost:${frontendPort}`
);
const frontendOrigin = frontendUrl.origin;
const frontendCookieDomain = frontendUrl.hostname;
module.exports = {
entry: './frontend/index.tsx',
@ -29,22 +37,22 @@ module.exports = {
},
hot: isDevelopment,
watchFiles: isDevelopment ? ['frontend/**/*'] : [],
port: 8080,
host: '0.0.0.0',
port: frontendPort,
host: frontendHost,
historyApiFallback: true,
proxy: [
{
context: ['/api', '/locales'],
target: 'http://localhost:3002',
target: backendUrl,
changeOrigin: true,
secure: false,
cookieDomainRewrite: 'localhost',
cookieDomainRewrite: frontendCookieDomain,
headers: {
'Access-Control-Allow-Origin': '*',
},
onProxyRes: function (proxyRes, req, res) {
proxyRes.headers['Access-Control-Allow-Origin'] =
'http://localhost:8080';
frontendOrigin;
proxyRes.headers['Access-Control-Allow-Credentials'] =
'true';
},