mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
Merge remote-tracking branch 'origin/master' into select-multiple-tags
This commit is contained in:
commit
5bd7d8b6b5
118 changed files with 4144 additions and 2587 deletions
|
|
@ -1,8 +1,8 @@
|
|||
<task>
|
||||
You are a translation extraction specialist that identifies untranslated strings in recently changed HTML and TypeScript files, extracts them to the translation files, and updates the source code to use translation keys.
|
||||
</task>
|
||||
<task>
|
||||
You are a translation extraction specialist that identifies untranslated strings in recently changed HTML and TypeScript files, extracts them to the translation files, and updates the source code to use translation keys.
|
||||
</task>
|
||||
|
||||
<workflow>
|
||||
<workflow>
|
||||
|
||||
## Phase 1: Identify Changed Files
|
||||
|
||||
|
|
@ -63,14 +63,14 @@ For each changed file:
|
|||
2. Verify changes compile correctly
|
||||
3. Report summary of changes
|
||||
|
||||
</workflow>
|
||||
</workflow>
|
||||
|
||||
<arguments>
|
||||
Optional arguments:
|
||||
- `--days <number>`: How many days back to check for changes (default: 7)
|
||||
- `--dry-run`: Preview changes without modifying files
|
||||
- `--interactive`: Confirm each extraction before applying
|
||||
</arguments>
|
||||
<arguments>
|
||||
Optional arguments:
|
||||
- `--days <number>`: How many days back to check for changes (default: 7)
|
||||
- `--dry-run`: Preview changes without modifying files
|
||||
- `--interactive`: Confirm each extraction before applying
|
||||
</arguments>
|
||||
|
||||
<extraction_patterns>
|
||||
|
||||
|
|
@ -88,25 +88,80 @@ For each changed file:
|
|||
<!-- Skip these -->
|
||||
<button>{{ T.BUTTON_CLICK }}</button>
|
||||
<mat-label>{{ 'LABEL_NAME' | translate }}</mat-label>
|
||||
|
||||
TypeScript Files // Extract these this.snackBar.open('Success!', 'OK'); const message =
|
||||
'Error occurred'; dialogConfig.data = { title: 'Confirm' }; // Skip these
|
||||
this.snackBar.open(T.SUCCESS_MESSAGE, T.OK); const message = T.ERROR_OCCURRED; Follow
|
||||
the existing pattern in en.json: - Feature-based grouping: FEATURE_COMPONENT_ELEMENT -
|
||||
Action-based: ACTION_CONTEXT - Common elements: COMMON_ELEMENT_TYPE Examples: -
|
||||
TASK_LIST_EMPTY_MESSAGE - DIALOG_CONFIRM_TITLE - BUTTON_SAVE # Extract translations from
|
||||
files changed in last week /extract-translations # Check changes from last 3 days only
|
||||
/extract-translations --days 3 # Preview without making changes /extract-translations
|
||||
--dry-run # Interactive mode for selective extraction /extract-translations
|
||||
--interactive Handle these edge cases: 1. Existing translations: Check if string already
|
||||
has a key 2. Dynamic strings: Skip template literals and concatenations 3. Special
|
||||
characters: Properly escape in JSON 4. File permissions: Ensure write access to
|
||||
translation files 5. Git conflicts: Warn if en.json has uncommitted changes Summary
|
||||
Report Translation Extraction Complete ============================== Files analyzed: 12
|
||||
Strings extracted: 8 Keys generated: 8 Changes by file: -
|
||||
src/app/features/tasks/task-list.component.html ✓ "No tasks" → T.TASK_LIST_EMPTY ✓ "Add
|
||||
task" → T.TASK_ADD_BUTTON - src/app/features/tasks/task.service.ts ✓ "Task saved" →
|
||||
T.TASK_SAVED_MESSAGE Translation file updated: src/assets/i18n/en.json Build command
|
||||
executed: npm run int
|
||||
</div>
|
||||
```
|
||||
|
||||
### TypeScript Files
|
||||
|
||||
```typescript
|
||||
// Extract these
|
||||
this.snackBar.open('Success!', 'OK');
|
||||
const message = 'Error occurred';
|
||||
dialogConfig.data = { title: 'Confirm' };
|
||||
|
||||
// Skip these
|
||||
this.snackBar.open(T.SUCCESS_MESSAGE, T.OK);
|
||||
const message = T.ERROR_OCCURRED;
|
||||
```
|
||||
|
||||
### Key Naming Conventions
|
||||
|
||||
Follow the existing pattern in en.json:
|
||||
|
||||
- Feature-based grouping: `FEATURE_COMPONENT_ELEMENT`
|
||||
- Action-based: `ACTION_CONTEXT`
|
||||
- Common elements: `COMMON_ELEMENT_TYPE`
|
||||
|
||||
Examples:
|
||||
|
||||
- `TASK_LIST_EMPTY_MESSAGE`
|
||||
- `DIALOG_CONFIRM_TITLE`
|
||||
- `BUTTON_SAVE`
|
||||
|
||||
</extraction_patterns>
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Extract translations from files changed in last week
|
||||
/extract-translations
|
||||
|
||||
# Check changes from last 3 days only
|
||||
/extract-translations --days 3
|
||||
|
||||
# Preview without making changes
|
||||
/extract-translations --dry-run
|
||||
|
||||
# Interactive mode for selective extraction
|
||||
/extract-translations --interactive
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
|
||||
Handle these edge cases:
|
||||
|
||||
1. Existing translations: Check if string already has a key
|
||||
2. Dynamic strings: Skip template literals and concatenations
|
||||
3. Special characters: Properly escape in JSON
|
||||
4. File permissions: Ensure write access to translation files
|
||||
5. Git conflicts: Warn if en.json has uncommitted changes
|
||||
|
||||
## Summary Report Example
|
||||
|
||||
```
|
||||
Translation Extraction Complete
|
||||
==============================
|
||||
Files analyzed: 12
|
||||
Strings extracted: 8
|
||||
Keys generated: 8
|
||||
|
||||
Changes by file:
|
||||
- src/app/features/tasks/task-list.component.html
|
||||
✓ "No tasks" → T.TASK_LIST_EMPTY
|
||||
✓ "Add task" → T.TASK_ADD_BUTTON
|
||||
- src/app/features/tasks/task.service.ts
|
||||
✓ "Task saved" → T.TASK_SAVED_MESSAGE
|
||||
|
||||
Translation file updated: src/assets/i18n/en.json
|
||||
Build command executed: npm run int
|
||||
```
|
||||
|
|
|
|||
43
.github/workflows/claude-code-review.yml
vendored
Normal file
43
.github/workflows/claude-code-review.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
49
.github/workflows/claude.yml
vendored
Normal file
49
.github/workflows/claude.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
114
AGENTS.md
114
AGENTS.md
|
|
@ -1,113 +1 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
1. Prefer functional programming patterns: Use pure functions, immutability, and avoid side effects where possible.
|
||||
2. KISS (Keep It Simple, Stupid): Aim for simplicity and clarity in code. Avoid unnecessary complexity and abstractions.
|
||||
3. DRY (Don't Repeat Yourself): Reuse code where possible. Create utility functions or services for common logic, but avoid unnecessary abstractions.
|
||||
4. Confirm understanding before making changes: If you're unsure about the purpose of a piece of code, ask for clarification rather than making assumptions.
|
||||
5. **ALWAYS** use `npm run checkFile <filepath>` on each file you modify to ensure proper formatting and linting. This runs both prettier and lint checks on individual files. Unless you want to lint and format multiple files, then use `npm run prettier` and `npm run lint` instead.
|
||||
6. When creating html templates, prefer plain html like `<table>` and `<div>`. Keep CSS styles to a minimum. Keep nesting to a minimum. Keep css classes to a minimum. Use Angular Material components where appropriate, but avoid overusing them.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Super Productivity is an advanced todo list and time tracking application built with Angular, Electron, and Capacitor for web, desktop, and mobile platforms.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm i -g @angular/cli
|
||||
npm i
|
||||
|
||||
# Run development server (web)
|
||||
ng serve # or npm run startFrontend
|
||||
|
||||
# Run with Electron (desktop)
|
||||
npm start
|
||||
|
||||
# Run tests
|
||||
npm test # Unit tests
|
||||
npm run e2e # E2E tests
|
||||
npm run prettier # Prettier formatting
|
||||
npm run lint # Linting
|
||||
|
||||
# Build for production
|
||||
npm run dist # All platforms Builds (all available in current environment)
|
||||
|
||||
# IMPORTANT: Check individual files before committing
|
||||
# Example: npm run checkFile src/app/features/tasks/task.service.ts
|
||||
# Use this command OFTEN when modifying files to ensure code quality
|
||||
npm run checkFile <filepath> # Runs prettier and lint on a single file
|
||||
# executes unit tests of a single spec file
|
||||
npm run test:file <filepath>
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit tests: `npm test` - Uses Jasmine/Karma, tests are co-located with source files (`.spec.ts`)
|
||||
- E2E tests: `npm run e2e` - Uses Nightwatch, located in `/e2e/src/`
|
||||
- Playwright E2E tests: Located in `/e2e/`
|
||||
- `npm run e2e:playwright` - Run all tests with minimal output (shows failures clearly)
|
||||
- `npm run e2e:playwright:file <path>` - Run a single test file with detailed output
|
||||
- Example: `npm run e2e:playwright:file tests/work-view/work-view.spec.ts`
|
||||
- Linting: `npm run lint` - ESLint for TypeScript, Stylelint for SCSS
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### State Management
|
||||
|
||||
The app uses NgRx (Redux pattern) for state management. Key state slices:
|
||||
|
||||
- Tasks, Projects, Tags - Core entities
|
||||
- WorkContext - Current working context (project/tag)
|
||||
- Global config - User preferences
|
||||
- Feature-specific states in `/src/app/features/`
|
||||
- Prefer Signals to Observables if possible
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Persistence Layer** (`/src/app/op-log/` and `/src/app/sync/`): Handles data storage (IndexedDB) and sync providers
|
||||
2. **Services** (`*.service.ts`): Business logic and state mutations via NgRx
|
||||
3. **Components**: (`*.component.ts`) Subscribe to state via selectors, dispatch actions for changes
|
||||
4. **Effects**: Handle side effects (persistence, sync, notifications)
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
- **Feature Modules**: Each major feature in `/src/app/features/` is self-contained with its own model, service, and components
|
||||
- **Lazy Loading**: Routes use dynamic imports for code splitting
|
||||
- **Model Validation**: Uses Typia for runtime type validation of data models
|
||||
- **IPC Communication**: Electron main/renderer communication via defined IPC events in `/electron/shared-with-frontend/ipc-events.const.ts`
|
||||
|
||||
### Cross-Platform Architecture
|
||||
|
||||
- **Web/PWA**: Standard Angular app with service worker
|
||||
- **Desktop**: Electron wraps the Angular app, adds native features (tray, shortcuts, idle detection)
|
||||
- **Mobile**: Capacitor bridges Angular to native Android/iOS
|
||||
|
||||
### Data Sync
|
||||
|
||||
- Multiple sync providers: Dropbox, WebDAV, local file
|
||||
- Sync is conflict-aware with vector-clock resolution
|
||||
- All sync operations go through `/src/app/imex/sync/`
|
||||
|
||||
## Important Development Notes
|
||||
|
||||
1. **Type Safety**: The codebase uses strict TypeScript. Always maintain proper typing.
|
||||
2. **State Updates**: Never mutate state directly. Use NgRx actions and reducers.
|
||||
3. **Testing**: Add tests for new features, especially in services and state management.
|
||||
4. **Translations**: UI strings must use the translation service (`T` or `TranslateService`).
|
||||
5. **Electron Context**: Check `IS_ELECTRON` before using Electron-specific features.
|
||||
6. **Privacy**: No analytics or tracking. User data stays local unless explicitly synced.
|
||||
|
||||
## 🚫 Known Anti-Patterns to Avoid
|
||||
|
||||
- `any` or untyped public APIs
|
||||
- Direct DOM access (use Angular bindings)
|
||||
- Adding side effects in constructors
|
||||
- Re-declaring styles that exist in Angular Material theme
|
||||
- Using deprecated Angular APIs (e.g., `NgModules` when not needed)
|
||||
Please read CLAUDE.md!!
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||
const val EXTRA_RELATED_ID = "related_id"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_REMINDER_TYPE = "reminder_type"
|
||||
const val EXTRA_USE_ALARM_STYLE = "use_alarm_style"
|
||||
|
||||
const val SNOOZE_DURATION_MS = 10 * 60 * 1000L // 10 minutes
|
||||
}
|
||||
|
|
@ -33,6 +34,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
|
||||
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
|
||||
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
|
||||
val useAlarmStyle = intent.getBooleanExtra(EXTRA_USE_ALARM_STYLE, false)
|
||||
|
||||
Log.d(TAG, "Snooze: notificationId=$notificationId, title=$title")
|
||||
|
||||
|
|
@ -50,7 +52,8 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||
relatedId,
|
||||
title,
|
||||
reminderType,
|
||||
newTriggerTime
|
||||
newTriggerTime,
|
||||
useAlarmStyle
|
||||
)
|
||||
|
||||
Log.d(TAG, "Rescheduled reminder for ${SNOOZE_DURATION_MS / 60000} minutes from now")
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class ReminderAlarmReceiver : BroadcastReceiver() {
|
|||
const val EXTRA_RELATED_ID = "related_id"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_REMINDER_TYPE = "reminder_type"
|
||||
const val EXTRA_USE_ALARM_STYLE = "use_alarm_style"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
|
|
@ -29,8 +30,9 @@ class ReminderAlarmReceiver : BroadcastReceiver() {
|
|||
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
|
||||
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
|
||||
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
|
||||
val useAlarmStyle = intent.getBooleanExtra(EXTRA_USE_ALARM_STYLE, false)
|
||||
|
||||
Log.d(TAG, "Alarm triggered: id=$notificationId, title=$title")
|
||||
Log.d(TAG, "Alarm triggered: id=$notificationId, title=$title, useAlarmStyle=$useAlarmStyle")
|
||||
|
||||
ReminderNotificationHelper.showNotification(
|
||||
context,
|
||||
|
|
@ -38,7 +40,8 @@ class ReminderAlarmReceiver : BroadcastReceiver() {
|
|||
reminderId,
|
||||
relatedId,
|
||||
title,
|
||||
reminderType
|
||||
reminderType,
|
||||
useAlarmStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,31 +24,54 @@ import com.superproductivity.superproductivity.receiver.ReminderAlarmReceiver
|
|||
*/
|
||||
object ReminderNotificationHelper {
|
||||
const val TAG = "ReminderNotifHelper"
|
||||
const val CHANNEL_ID = "sp_reminders_channel"
|
||||
const val CHANNEL_ID_ALARM = "sp_reminders_channel"
|
||||
const val CHANNEL_ID_REGULAR = "sp_reminders_regular_channel"
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
fun createChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
// Alarm-style channel (louder, more intrusive)
|
||||
val alarmSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
|
||||
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
val alarmAudioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
val alarmChannel = NotificationChannel(
|
||||
CHANNEL_ID_ALARM,
|
||||
"Reminders (Alarm)",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Alarm-style task reminders with louder sound"
|
||||
setShowBadge(true)
|
||||
enableVibration(true)
|
||||
vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500)
|
||||
setSound(alarmSound, alarmAudioAttributes)
|
||||
}
|
||||
notificationManager.createNotificationChannel(alarmChannel)
|
||||
|
||||
// Regular notification channel (standard notification sound)
|
||||
val notificationSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
|
||||
val notificationAudioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
|
||||
val regularChannel = NotificationChannel(
|
||||
CHANNEL_ID_REGULAR,
|
||||
"Reminders",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Task and note reminders"
|
||||
setShowBadge(true)
|
||||
enableVibration(true)
|
||||
vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500)
|
||||
setSound(alarmSound, audioAttributes)
|
||||
setSound(notificationSound, notificationAudioAttributes)
|
||||
}
|
||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
notificationManager.createNotificationChannel(regularChannel)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,9 +82,10 @@ object ReminderNotificationHelper {
|
|||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String,
|
||||
triggerAtMs: Long
|
||||
triggerAtMs: Long,
|
||||
useAlarmStyle: Boolean = false
|
||||
) {
|
||||
Log.d(TAG, "Scheduling reminder: id=$notificationId, title=$title")
|
||||
Log.d(TAG, "Scheduling reminder: id=$notificationId, title=$title, useAlarmStyle=$useAlarmStyle")
|
||||
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
|
|
@ -72,6 +96,7 @@ object ReminderNotificationHelper {
|
|||
putExtra(ReminderAlarmReceiver.EXTRA_RELATED_ID, relatedId)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_TITLE, title)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_REMINDER_TYPE, reminderType)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_USE_ALARM_STYLE, useAlarmStyle)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
|
|
@ -109,9 +134,12 @@ object ReminderNotificationHelper {
|
|||
reminderId: String,
|
||||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String
|
||||
reminderType: String,
|
||||
useAlarmStyle: Boolean = false
|
||||
) {
|
||||
createChannel(context)
|
||||
createChannels(context)
|
||||
|
||||
val channelId = if (useAlarmStyle) CHANNEL_ID_ALARM else CHANNEL_ID_REGULAR
|
||||
|
||||
// Tapping notification opens app
|
||||
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
|
|
@ -130,13 +158,16 @@ object ReminderNotificationHelper {
|
|||
putExtra(ReminderActionReceiver.EXTRA_RELATED_ID, relatedId)
|
||||
putExtra(ReminderActionReceiver.EXTRA_TITLE, title)
|
||||
putExtra(ReminderActionReceiver.EXTRA_REMINDER_TYPE, reminderType)
|
||||
putExtra(ReminderActionReceiver.EXTRA_USE_ALARM_STYLE, useAlarmStyle)
|
||||
}
|
||||
val snoozePendingIntent = PendingIntent.getBroadcast(
|
||||
context, notificationId * 10 + 1, snoozeIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
val category = if (useAlarmStyle) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_REMINDER
|
||||
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_stat_sp)
|
||||
.setContentTitle(title)
|
||||
.setContentText(if (reminderType == "TASK") "Task reminder" else "Note reminder")
|
||||
|
|
@ -144,7 +175,7 @@ object ReminderNotificationHelper {
|
|||
.setAutoCancel(true)
|
||||
.addAction(0, "Snooze 10m", snoozePendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.setCategory(category)
|
||||
.build()
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -194,7 +194,8 @@ class JavaScriptInterface(
|
|||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String,
|
||||
triggerAtMs: Long
|
||||
triggerAtMs: Long,
|
||||
useAlarmStyle: Boolean
|
||||
) {
|
||||
safeCall("Failed to schedule native reminder") {
|
||||
ReminderNotificationHelper.scheduleReminder(
|
||||
|
|
@ -204,7 +205,8 @@ class JavaScriptInterface(
|
|||
relatedId,
|
||||
title,
|
||||
reminderType,
|
||||
triggerAtMs
|
||||
triggerAtMs,
|
||||
useAlarmStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,15 +230,29 @@ export class SuperSyncPage extends BasePage {
|
|||
intervals: [500, 1000, 1500, 2000, 2500, 3000],
|
||||
});
|
||||
|
||||
// Click the SuperSync option with retry to handle dropdown closing issues
|
||||
// Click the SuperSync option and verify selection was applied
|
||||
await expect(async () => {
|
||||
// Check if option is visible - if not, dropdown may have closed unexpectedly
|
||||
// Check if dropdown is open - if not, we may need to reopen it
|
||||
if (!(await dropdownPanel.isVisible())) {
|
||||
await this.providerSelect.click({ timeout: 2000, force: true });
|
||||
await dropdownPanel.waitFor({ state: 'visible', timeout: 3000 });
|
||||
}
|
||||
|
||||
// Click the option if visible
|
||||
if (await superSyncOption.isVisible()) {
|
||||
await superSyncOption.click({ timeout: 2000 });
|
||||
}
|
||||
|
||||
// Wait for dropdown panel to close
|
||||
await dropdownPanel.waitFor({ state: 'detached', timeout: 3000 });
|
||||
|
||||
// CRITICAL: Verify selection was actually applied
|
||||
const selectedText = await this.providerSelect
|
||||
.locator('.mat-mdc-select-value-text')
|
||||
.textContent();
|
||||
if (!selectedText?.includes('SuperSync')) {
|
||||
throw new Error(`Provider selection not applied. Selected: "${selectedText}"`);
|
||||
}
|
||||
}).toPass({
|
||||
timeout: 15000,
|
||||
intervals: [500, 1000, 1500, 2000],
|
||||
|
|
@ -246,16 +260,23 @@ export class SuperSyncPage extends BasePage {
|
|||
|
||||
// Wait for formly to re-render SuperSync-specific fields after provider selection
|
||||
// The hideExpression on these fields triggers a re-render that needs time to complete
|
||||
// NOTE: The mat-select UI updates immediately, but the formly model update is async.
|
||||
// We must wait for the actual DOM elements to appear, not just the UI selection.
|
||||
await this.page.waitForLoadState('networkidle').catch(() => {});
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Fill Access Token first (it's outside the collapsible)
|
||||
// Use toPass() to handle slow formly rendering of the textarea
|
||||
// Use toPass() to handle slow formly model updates and hideExpression re-evaluation
|
||||
// First wait for the wrapper element to exist (formly has processed the model change)
|
||||
// Then wait for the textarea inside to be visible
|
||||
await expect(async () => {
|
||||
// Check if the wrapper element exists (formly hideExpression has evaluated)
|
||||
const wrapper = this.page.locator('.e2e-accessToken');
|
||||
await wrapper.waitFor({ state: 'attached', timeout: 3000 });
|
||||
// Then check if the textarea is visible
|
||||
await this.accessTokenInput.waitFor({ state: 'visible', timeout: 3000 });
|
||||
}).toPass({
|
||||
timeout: 15000,
|
||||
intervals: [500, 1000, 1500, 2000],
|
||||
timeout: 30000,
|
||||
intervals: [500, 1000, 1500, 2000, 3000],
|
||||
});
|
||||
await this.accessTokenInput.fill(config.accessToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ export default defineConfig({
|
|||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:4242',
|
||||
|
||||
/* Configure downloads to go to test output directory, not ~/Downloads */
|
||||
downloadsPath: path.join(__dirname, '..', '.tmp', 'e2e-test-results', 'downloads'),
|
||||
|
||||
/* Collect trace on failure for better debugging. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'retain-on-failure',
|
||||
|
||||
|
|
|
|||
|
|
@ -251,4 +251,123 @@ test.describe('Bug #5954: Pomodoro timer sync issues', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('No valid task available (Bug #5954 comment)', () => {
|
||||
/**
|
||||
* Tests for the scenario where user starts focus mode but all tasks are done.
|
||||
* The fix ensures the focus overlay appears so user can select/create a task.
|
||||
* https://github.com/super-productivity/super-productivity/issues/5954#issuecomment-3753395324
|
||||
*/
|
||||
test('should keep overlay visible when starting session with all tasks done', async ({
|
||||
page,
|
||||
testPrefix,
|
||||
taskPage,
|
||||
}) => {
|
||||
const workViewPage = new WorkViewPage(page, testPrefix);
|
||||
const focusModeOverlay = page.locator('focus-mode-overlay');
|
||||
const mainFocusButton = page
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'center_focus_strong' });
|
||||
|
||||
// Step 1: Create a task and mark it as done immediately
|
||||
await workViewPage.waitForTaskList();
|
||||
await workViewPage.addTask('CompletedTaskTest');
|
||||
|
||||
const task = page.locator('task').first();
|
||||
await expect(task).toBeVisible();
|
||||
|
||||
// Mark task as done
|
||||
await taskPage.markTaskAsDone(task);
|
||||
await expect(task).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
|
||||
// Step 2: Open focus mode (no task is being tracked)
|
||||
await mainFocusButton.click();
|
||||
await expect(focusModeOverlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Step 3: Select Pomodoro mode and start session
|
||||
await selectPomodoroMode(page);
|
||||
|
||||
const playButton = page.locator('focus-mode-main button.play-button');
|
||||
await expect(playButton).toBeVisible({ timeout: 2000 });
|
||||
await playButton.click();
|
||||
|
||||
// Wait for any countdown to complete
|
||||
const countdownComponent = page.locator('focus-mode-countdown');
|
||||
try {
|
||||
const isVisible = await countdownComponent.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
await expect(countdownComponent).not.toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
} catch {
|
||||
// Countdown may be skipped
|
||||
}
|
||||
|
||||
// Step 4: Verify the overlay remains visible (fix for bug #5954)
|
||||
// The showFocusOverlay action should be dispatched when no valid task exists
|
||||
await expect(focusModeOverlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Session should be in progress (timer running)
|
||||
const completeSessionBtn = page.locator('focus-mode-main .complete-session-btn');
|
||||
await expect(completeSessionBtn).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should keep overlay visible when last tracked task was completed', async ({
|
||||
page,
|
||||
testPrefix,
|
||||
taskPage,
|
||||
}) => {
|
||||
const workViewPage = new WorkViewPage(page, testPrefix);
|
||||
const focusModeOverlay = page.locator('focus-mode-overlay');
|
||||
const mainFocusButton = page
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'center_focus_strong' });
|
||||
|
||||
// Step 1: Create task and start tracking
|
||||
await workViewPage.waitForTaskList();
|
||||
await workViewPage.addTask('TrackThenCompleteTest');
|
||||
|
||||
const task = page.locator('task').first();
|
||||
await expect(task).toBeVisible();
|
||||
|
||||
// Start tracking the task
|
||||
await task.hover();
|
||||
const playButton = page.locator('.play-btn.tour-playBtn').first();
|
||||
await playButton.waitFor({ state: 'visible' });
|
||||
await playButton.click();
|
||||
await expect(task).toHaveClass(/isCurrent/, { timeout: 5000 });
|
||||
|
||||
// Step 2: Mark task as done (this stops tracking)
|
||||
await taskPage.markTaskAsDone(task);
|
||||
await expect(task).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
await expect(task).not.toHaveClass(/isCurrent/, { timeout: 5000 });
|
||||
|
||||
// Step 3: Open focus mode and try to start session
|
||||
await mainFocusButton.click();
|
||||
await expect(focusModeOverlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await selectPomodoroMode(page);
|
||||
|
||||
const sessionPlayButton = page.locator('focus-mode-main button.play-button');
|
||||
await expect(sessionPlayButton).toBeVisible({ timeout: 2000 });
|
||||
await sessionPlayButton.click();
|
||||
|
||||
// Wait for countdown
|
||||
const countdownComponent = page.locator('focus-mode-countdown');
|
||||
try {
|
||||
const isVisible = await countdownComponent.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
await expect(countdownComponent).not.toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
} catch {
|
||||
// Countdown may be skipped
|
||||
}
|
||||
|
||||
// Step 4: Verify overlay stays visible for task selection
|
||||
await expect(focusModeOverlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Session should still start (timer runs, user can select task from overlay)
|
||||
const completeSessionBtn = page.locator('focus-mode-main .complete-session-btn');
|
||||
await expect(completeSessionBtn).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ test.describe('Mat Menu Touch Submenu Fix', () => {
|
|||
const tagBtn = page.locator('.mat-mdc-menu-content button', { hasText: tagName });
|
||||
await tagBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Wait for 300ms touch protection delay to expire, then a small buffer
|
||||
// Wait for 300ms touch protection delay to expire, plus buffer for CI stability
|
||||
// This is a timing-based protection feature being tested, so timeout is justified
|
||||
await page.waitForTimeout(350);
|
||||
await page.waitForTimeout(450);
|
||||
|
||||
// Click on the tag - should work after delay
|
||||
await tagBtn.click();
|
||||
|
|
|
|||
509
e2e/tests/sync/supersync-backup-import-id-mismatch.spec.ts
Normal file
509
e2e/tests/sync/supersync-backup-import-id-mismatch.spec.ts
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
hasTask,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
/**
|
||||
* SuperSync Backup Import ID Mismatch Bug Reproduction Test
|
||||
*
|
||||
* BUG: When a backup is imported and synced, the client creates a BACKUP_IMPORT
|
||||
* operation with a local ID. However, uploadSnapshot() does NOT send this ID to
|
||||
* the server - the server generates its own ID. When the client later downloads
|
||||
* operations, it doesn't recognize the server's BACKUP_IMPORT (different ID) as
|
||||
* a duplicate, so it RE-APPLIES the old backup state, overwriting any tasks
|
||||
* created after the import.
|
||||
*
|
||||
* ROOT CAUSE: Missing op.id parameter in uploadSnapshot() interface at
|
||||
* src/app/op-log/sync-providers/provider.interface.ts:277-284
|
||||
*
|
||||
* EXPECTED: Tasks created after backup import should survive subsequent syncs.
|
||||
* ACTUAL (BUG): Tasks created after backup import are LOST because the old
|
||||
* BACKUP_IMPORT state is re-applied.
|
||||
*
|
||||
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-backup-import-id-mismatch.spec.ts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper to export backup by triggering the UI export button.
|
||||
* This ensures the backup data is in the correct format for import.
|
||||
*/
|
||||
const exportBackup = async (page: SimulatedE2EClient['page']): Promise<string> => {
|
||||
// Navigate to settings page
|
||||
await page.goto('/#/config');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Expand Import/Export section
|
||||
const importExportSection = page.locator('collapsible:has-text("Import/Export")');
|
||||
await importExportSection.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const collapsibleHeader = importExportSection.locator('.collapsible-header, .header');
|
||||
await collapsibleHeader.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Set up download handler before clicking export
|
||||
const tempDir = os.tmpdir();
|
||||
const backupPath = path.join(tempDir, `sp-backup-idmismatch-${Date.now()}.json`);
|
||||
|
||||
// Wait for download and save to temp file
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
// Click the export button (export current data)
|
||||
const exportBtn = page.locator('file-imex button:has-text("Export")');
|
||||
await exportBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await exportBtn.click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
await download.saveAs(backupPath);
|
||||
|
||||
// Navigate back to tasks
|
||||
await page.goto('/#/tag/TODAY/tasks');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
return backupPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to import backup file.
|
||||
* Returns true if import succeeded, false if it failed.
|
||||
*/
|
||||
const importBackup = async (
|
||||
page: SimulatedE2EClient['page'],
|
||||
backupPath: string,
|
||||
): Promise<boolean> => {
|
||||
// Track console errors during import
|
||||
let importFailed = false;
|
||||
const errorHandler = (msg: { text: () => string }): void => {
|
||||
if (msg.text().includes('Import process failed')) {
|
||||
importFailed = true;
|
||||
}
|
||||
};
|
||||
page.on('console', errorHandler);
|
||||
|
||||
await page.goto('/#/config');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const importExportSection = page.locator('collapsible:has-text("Import/Export")');
|
||||
await importExportSection.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const collapsibleHeader = importExportSection.locator('.collapsible-header, .header');
|
||||
await collapsibleHeader.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const fileInput = page.locator('file-imex input[type="file"]');
|
||||
await fileInput.setInputFiles(backupPath);
|
||||
|
||||
// Wait for import to complete (app navigates to TODAY tag) or error
|
||||
const startTime = Date.now();
|
||||
const timeout = 30000;
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (importFailed) {
|
||||
page.off('console', errorHandler);
|
||||
return false;
|
||||
}
|
||||
const url = page.url();
|
||||
if (url.includes('tag') && url.includes('TODAY')) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
page.off('console', errorHandler);
|
||||
return !importFailed;
|
||||
};
|
||||
|
||||
test.describe('@supersync Backup Import ID Mismatch Bug', () => {
|
||||
/**
|
||||
* CRITICAL BUG VERIFICATION TEST
|
||||
*
|
||||
* This test verifies that the BACKUP_IMPORT operation ID mismatch bug exists.
|
||||
*
|
||||
* BUG: When uploading a BACKUP_IMPORT via uploadSnapshot(), the client's op.id
|
||||
* is NOT sent to the server. The server generates its own ID for the operation.
|
||||
*
|
||||
* This test:
|
||||
* 1. Client A imports backup (creates BACKUP_IMPORT with LOCAL ID)
|
||||
* 2. Client A syncs (uploads BACKUP_IMPORT - server generates DIFFERENT ID)
|
||||
* 3. Query IndexedDB for local BACKUP_IMPORT op ID
|
||||
* 4. Query server API for the operation ID it stored
|
||||
* 5. VERIFY: The IDs are DIFFERENT (this proves the bug exists)
|
||||
*
|
||||
* When the bug is FIXED, the IDs should match and this test should FAIL.
|
||||
*/
|
||||
test('BUG: Server stores BACKUP_IMPORT with different ID than client (ID mismatch)', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let backupPath: string | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId + '-idmismatch');
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Setup - Connect and sync ============
|
||||
console.log('[ID Mismatch Test] Phase 1: Setting up client');
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[ID Mismatch Test] Client A connected and synced');
|
||||
|
||||
// Create initial task that will be in the backup
|
||||
const initialTaskName = `InitialTask-${testRunId}`;
|
||||
await clientA.workView.addTask(initialTaskName);
|
||||
await waitForTask(clientA.page, initialTaskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log(`[ID Mismatch Test] Created and synced initial task`);
|
||||
|
||||
// ============ PHASE 2: Export and import backup ============
|
||||
console.log('[ID Mismatch Test] Phase 2: Export and import backup');
|
||||
|
||||
backupPath = await exportBackup(clientA.page);
|
||||
const importSuccess = await importBackup(clientA.page, backupPath);
|
||||
if (!importSuccess) {
|
||||
throw new Error('Backup import failed');
|
||||
}
|
||||
console.log(
|
||||
'[ID Mismatch Test] Backup imported (BACKUP_IMPORT created with local ID)',
|
||||
);
|
||||
|
||||
// ============ PHASE 3: Get local BACKUP_IMPORT op ID from IndexedDB ============
|
||||
console.log('[ID Mismatch Test] Phase 3: Getting local BACKUP_IMPORT op ID');
|
||||
|
||||
// Wait for IndexedDB writes to complete
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
|
||||
const localOpData = await clientA.page.evaluate(async () => {
|
||||
// Open SUP_OPS database
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open('SUP_OPS');
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
// Compact operation format uses short keys:
|
||||
// o = opType, a = actionType, e = entityType, etc.
|
||||
interface CompactOp {
|
||||
id: string;
|
||||
o: string; // opType
|
||||
a: string; // actionType short code
|
||||
e: string; // entityType
|
||||
}
|
||||
|
||||
// Get all operations from the 'ops' store
|
||||
const ops = await new Promise<Array<{ seq: number; op: CompactOp }>>(
|
||||
(resolve, reject) => {
|
||||
const tx = db.transaction('ops', 'readonly');
|
||||
const store = tx.objectStore('ops');
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
},
|
||||
);
|
||||
|
||||
db.close();
|
||||
|
||||
// Debug: Log all operations
|
||||
const debugInfo = ops.map((entry) => ({
|
||||
seq: entry.seq,
|
||||
opType: entry.op?.o,
|
||||
entityType: entry.op?.e,
|
||||
id: entry.op?.id?.substring(0, 20),
|
||||
}));
|
||||
|
||||
// Find BACKUP_IMPORT operation (opType 'BACKUP_IMPORT')
|
||||
const backupImportOp = ops.find((entry) => entry.op?.o === 'BACKUP_IMPORT');
|
||||
|
||||
return {
|
||||
opId: backupImportOp?.op.id || null,
|
||||
debugInfo,
|
||||
totalOps: ops.length,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[ID Mismatch Test] Debug: Found ${localOpData.totalOps} ops:`,
|
||||
JSON.stringify(localOpData.debugInfo),
|
||||
);
|
||||
const localOpId = localOpData.opId;
|
||||
|
||||
console.log(`[ID Mismatch Test] Local BACKUP_IMPORT op ID: ${localOpId}`);
|
||||
expect(localOpId).toBeTruthy();
|
||||
|
||||
// ============ PHASE 4: Sync to upload BACKUP_IMPORT ============
|
||||
console.log('[ID Mismatch Test] Phase 4: Syncing to upload BACKUP_IMPORT');
|
||||
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[ID Mismatch Test] Sync completed - BACKUP_IMPORT uploaded to server');
|
||||
|
||||
// ============ PHASE 5: Query server for the operation ID it stored ============
|
||||
console.log('[ID Mismatch Test] Phase 5: Querying server for stored operation ID');
|
||||
|
||||
// Query the PostgreSQL database directly to get the server's operation ID
|
||||
// The download API doesn't return the SYNC_IMPORT due to server optimization
|
||||
// (it skips to the SYNC_IMPORT's seq but doesn't include the operation itself)
|
||||
const { execSync } = await import('child_process');
|
||||
|
||||
// Query the database for SYNC_IMPORT operations for this user
|
||||
// Database uses snake_case column names: user_id, op_type, server_seq
|
||||
const dbQuery =
|
||||
`SELECT id, op_type FROM operations ` +
|
||||
`WHERE user_id = '${user.userId}' ` +
|
||||
`AND op_type IN ('SYNC_IMPORT', 'BACKUP_IMPORT', 'REPAIR') ` +
|
||||
`ORDER BY server_seq DESC LIMIT 1;`;
|
||||
|
||||
let serverOpId: string | null = null;
|
||||
try {
|
||||
const dbResult = execSync(
|
||||
`docker exec super-productivity-db-1 psql -U supersync -d supersync_db -t -A -c "${dbQuery}"`,
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
|
||||
console.log(`[ID Mismatch Test] Database query result: ${dbResult}`);
|
||||
|
||||
if (dbResult) {
|
||||
// Result format: "uuid|op_type"
|
||||
const [opId] = dbResult.split('|');
|
||||
serverOpId = opId || null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[ID Mismatch Test] Database query failed:`, err);
|
||||
}
|
||||
|
||||
console.log(`[ID Mismatch Test] Server operation ID from database: ${serverOpId}`);
|
||||
|
||||
// ============ PHASE 6: CRITICAL ASSERTION - IDs should match ============
|
||||
console.log('[ID Mismatch Test] Phase 6: Comparing local and server op IDs');
|
||||
console.log(`[ID Mismatch Test] Local ID: ${localOpId}`);
|
||||
console.log(`[ID Mismatch Test] Server ID: ${serverOpId}`);
|
||||
|
||||
// BUG: The IDs are DIFFERENT because uploadSnapshot doesn't send op.id
|
||||
// When the bug is FIXED, this assertion should PASS (IDs match)
|
||||
// Currently, this test FAILS because the IDs are different
|
||||
expect(serverOpId).toBeTruthy();
|
||||
expect(
|
||||
localOpId,
|
||||
'BUG: Local BACKUP_IMPORT op ID should match server op ID. ' +
|
||||
'The server generated a different ID because uploadSnapshot() does not send op.id. ' +
|
||||
`Local: ${localOpId}, Server: ${serverOpId}`,
|
||||
).toBe(serverOpId);
|
||||
|
||||
console.log('[ID Mismatch Test] ✓ IDs MATCH - Bug is fixed!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (backupPath && fs.existsSync(backupPath)) {
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* CRITICAL BUG REPRODUCTION TEST - With concurrent client causing rejection
|
||||
*
|
||||
* This test more closely reproduces the actual bug scenario:
|
||||
* 1. Client A imports backup → BACKUP_IMPORT with local ID X
|
||||
* 2. Client A syncs → server creates BACKUP_IMPORT with ID Y
|
||||
* 3. Client A creates new task
|
||||
* 4. Client B syncs (causes concurrent operations on server)
|
||||
* 5. Client A syncs → some ops may be rejected, triggering re-download
|
||||
* 6. Re-download returns BACKUP_IMPORT with ID Y
|
||||
* 7. Client A doesn't recognize ID Y → re-applies old state → DATA LOST
|
||||
*/
|
||||
test('Tasks should survive when concurrent client causes operation rejection', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
let backupPath: string | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId + '-concurrent');
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ Setup: Client A connects first ============
|
||||
console.log('[Concurrent Test] Phase 1: Client A setup');
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Create initial task
|
||||
const initialTaskName = `InitialConcurrent-${testRunId}`;
|
||||
await clientA.workView.addTask(initialTaskName);
|
||||
await waitForTask(clientA.page, initialTaskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log(`[Concurrent Test] Created initial task: ${initialTaskName}`);
|
||||
|
||||
// ============ Client A exports and imports backup ============
|
||||
console.log('[Concurrent Test] Phase 2: Export and import backup');
|
||||
|
||||
backupPath = await exportBackup(clientA.page);
|
||||
const importSuccess = await importBackup(clientA.page, backupPath);
|
||||
if (!importSuccess) {
|
||||
throw new Error('Backup import failed');
|
||||
}
|
||||
console.log('[Concurrent Test] Backup imported');
|
||||
|
||||
// Re-setup sync and sync (uploads BACKUP_IMPORT)
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log(
|
||||
'[Concurrent Test] BACKUP_IMPORT uploaded (server assigns different ID)',
|
||||
);
|
||||
|
||||
// ============ Client A creates task AFTER backup import ============
|
||||
console.log('[Concurrent Test] Phase 3: Create task after import');
|
||||
|
||||
const taskAfterImportName = `TaskAfterConcurrent-${testRunId}`;
|
||||
await clientA.workView.addTask(taskAfterImportName);
|
||||
await waitForTask(clientA.page, taskAfterImportName);
|
||||
console.log(`[Concurrent Test] Created post-import task: ${taskAfterImportName}`);
|
||||
|
||||
// ============ Client B joins and creates concurrent operations ============
|
||||
console.log('[Concurrent Test] Phase 4: Client B creates concurrent operations');
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Client B creates a task (this creates concurrent ops on server)
|
||||
const clientBTask = `ClientBTask-${testRunId}`;
|
||||
await clientB.workView.addTask(clientBTask);
|
||||
await waitForTask(clientB.page, clientBTask);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log(`[Concurrent Test] Client B created task: ${clientBTask}`);
|
||||
|
||||
// ============ Client A syncs (may trigger rejection/re-download) ============
|
||||
console.log(
|
||||
'[Concurrent Test] Phase 5: Client A syncs with concurrent server state',
|
||||
);
|
||||
|
||||
// Verify task exists before sync
|
||||
const hasBefore = await hasTask(clientA.page, taskAfterImportName);
|
||||
console.log(`[Concurrent Test] Before sync - TaskAfterImport exists: ${hasBefore}`);
|
||||
expect(hasBefore).toBe(true);
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Concurrent Test] Client A sync completed');
|
||||
|
||||
// Wait for state to settle
|
||||
await clientA.page.waitForTimeout(2000);
|
||||
|
||||
// ============ Verify task survived ============
|
||||
console.log('[Concurrent Test] Phase 6: Verify task survived');
|
||||
|
||||
await clientA.page.goto('/#/tag/TODAY/tasks');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
|
||||
const hasAfter = await hasTask(clientA.page, taskAfterImportName);
|
||||
console.log(`[Concurrent Test] After sync - TaskAfterImport exists: ${hasAfter}`);
|
||||
|
||||
// CRITICAL ASSERTION
|
||||
expect(
|
||||
hasAfter,
|
||||
'TaskAfterImport should survive concurrent sync. ' +
|
||||
'Bug: BACKUP_IMPORT was re-applied with server ID.',
|
||||
).toBe(true);
|
||||
|
||||
console.log('[Concurrent Test] ✓ Test PASSED');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
if (backupPath && fs.existsSync(backupPath)) {
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Additional verification: Check that a second sync also doesn't lose data
|
||||
*
|
||||
* This ensures the fix is robust - even multiple syncs after backup import
|
||||
* should not lose data.
|
||||
*/
|
||||
test('Multiple syncs after backup import should not lose data', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let backupPath: string | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId + '-multisync');
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Create initial task
|
||||
const initialTaskName = `InitialMulti-${testRunId}`;
|
||||
await clientA.workView.addTask(initialTaskName);
|
||||
await waitForTask(clientA.page, initialTaskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Export and import backup
|
||||
backupPath = await exportBackup(clientA.page);
|
||||
const importSuccess = await importBackup(clientA.page, backupPath);
|
||||
if (!importSuccess) {
|
||||
throw new Error('Backup import failed - cannot test multi-sync scenario');
|
||||
}
|
||||
|
||||
// Re-setup sync and sync after import
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Create task after import
|
||||
const taskAfterImportName = `TaskAfterMulti-${testRunId}`;
|
||||
await clientA.workView.addTask(taskAfterImportName);
|
||||
await waitForTask(clientA.page, taskAfterImportName);
|
||||
|
||||
// Multiple syncs - each could potentially trigger the bug
|
||||
console.log('[Multi-Sync Test] Performing multiple syncs...');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log(`[Multi-Sync Test] Sync ${i} completed`);
|
||||
|
||||
await clientA.page.waitForTimeout(500);
|
||||
|
||||
// Check after each sync
|
||||
const hasNew = await hasTask(clientA.page, taskAfterImportName);
|
||||
expect(
|
||||
hasNew,
|
||||
`TaskAfterImport should exist after sync ${i}. Bug: BACKUP_IMPORT re-applied.`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
console.log('[Multi-Sync Test] ✓ All syncs completed, task survived');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (backupPath && fs.existsSync(backupPath)) {
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1333,11 +1333,13 @@ test.describe('@supersync SuperSync LWW Conflict Resolution', () => {
|
|||
const taskLocatorB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
|
||||
.first();
|
||||
await taskLocatorB.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await taskLocatorB.dblclick();
|
||||
const titleInputB = clientB.page.locator(
|
||||
'input.mat-mdc-input-element:focus, textarea:focus',
|
||||
);
|
||||
const titleInputB = clientB.page
|
||||
.locator('input.mat-mdc-input-element, textarea')
|
||||
.first();
|
||||
await titleInputB.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await titleInputB.focus();
|
||||
await titleInputB.fill(`${taskName}-Updated`);
|
||||
await clientB.page.keyboard.press('Enter');
|
||||
await clientB.page.waitForTimeout(300);
|
||||
|
|
|
|||
|
|
@ -75,12 +75,11 @@ test.describe('WebDAV Sync Advanced Features', () => {
|
|||
const parentTaskB = pageB.locator('task', { hasText: parentTaskName }).first();
|
||||
await expect(parentTaskB).toBeVisible();
|
||||
|
||||
// Check for subtask count - expand first
|
||||
await parentTaskB.click(); // Ensure focus/expanded? Usually auto-expanded.
|
||||
|
||||
// Use more specific locator for subtasks
|
||||
// Wait for sub-task list to be rendered with correct count
|
||||
// Sub-tasks are visible by default (no click needed to expand)
|
||||
const subTaskList = pageB.locator(`task-list[listid="SUB"]`);
|
||||
await expect(subTaskList.locator('task')).toHaveCount(2);
|
||||
await expect(subTaskList).toBeVisible({ timeout: 10000 });
|
||||
await expect(subTaskList.locator('task')).toHaveCount(2, { timeout: 10000 });
|
||||
await expect(subTaskList.locator('task', { hasText: 'Sub Task 1' })).toBeVisible();
|
||||
await expect(subTaskList.locator('task', { hasText: 'Sub Task 2' })).toBeVisible();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>Procrastination Buster</title>
|
||||
<title>AI Productivity Prompts</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,55 +1,8 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import solidPlugin from 'vite-plugin-solid';
|
||||
import { resolve } from 'path';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { superProductivityPlugin } from '@super-productivity/vite-plugin';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [
|
||||
solidPlugin(),
|
||||
{
|
||||
name: 'copy-files',
|
||||
closeBundle() {
|
||||
// Copy manifest.json to dist
|
||||
const manifestSrc = path.resolve(__dirname, 'src/manifest.json');
|
||||
const manifestDest = path.resolve(__dirname, 'dist/manifest.json');
|
||||
fs.copyFileSync(manifestSrc, manifestDest);
|
||||
|
||||
// Copy icon.svg to dist root
|
||||
const iconSrc = path.resolve(__dirname, 'src/assets/icon.svg');
|
||||
const iconDest = path.resolve(__dirname, 'dist/icon.svg');
|
||||
fs.copyFileSync(iconSrc, iconDest);
|
||||
|
||||
// Move index.html from src subdirectory to root
|
||||
const htmlSrc = path.resolve(__dirname, 'dist/src/index.html');
|
||||
const htmlDest = path.resolve(__dirname, 'dist/index.html');
|
||||
if (fs.existsSync(htmlSrc)) {
|
||||
fs.renameSync(htmlSrc, htmlDest);
|
||||
// Remove the src directory
|
||||
fs.rmSync(path.resolve(__dirname, 'dist/src'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/index.html'),
|
||||
plugin: resolve(__dirname, 'src/plugin.ts'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: '[name]-[hash].js',
|
||||
assetFileNames: '[name].[ext]',
|
||||
},
|
||||
},
|
||||
copyPublicDir: false,
|
||||
},
|
||||
publicDir: 'src/assets',
|
||||
plugins: [solidPlugin(), superProductivityPlugin()],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ With this Privacy Policy, we inform you about the type, scope, and purpose of th
|
|||
## 2. Controller
|
||||
|
||||
**Johannes Millan**
|
||||
[Insert Street and House Number here]
|
||||
[Insert Zip Code and City here]
|
||||
Germany
|
||||
|
||||
Email: contact@super-productivity.com
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ Mit dieser Datenschutzerklärung informieren wir Sie über die Art, den Umfang u
|
|||
## 2. Verantwortlicher
|
||||
|
||||
**Johannes Millan**
|
||||
[Hier Straße und Hausnummer ergänzen]
|
||||
[Hier PLZ und Ort ergänzen]
|
||||
Deutschland
|
||||
|
||||
E-Mail: contact@super-productivity.com
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@
|
|||
margin-bottom: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
h3 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text);
|
||||
|
|
@ -57,6 +62,11 @@
|
|||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.note {
|
||||
font-style: italic;
|
||||
color: var(--text-light);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -68,83 +78,243 @@
|
|||
>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>Last updated: December 9, 2025</p>
|
||||
|
||||
<h2>1. Information We Collect</h2>
|
||||
<p>
|
||||
We collect information you provide directly to us, such as when you create or
|
||||
modify your account, request customer support, or communicate with us.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Account Information:</strong> When you register, we collect your email
|
||||
address and password (hashed).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Sync Data:</strong> We store the productivity data (tasks, settings,
|
||||
etc.) you synchronize through our Service.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Usage Data:</strong> We may collect information about how you access and
|
||||
use the Service.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>2. How We Use Your Information</h2>
|
||||
<p>
|
||||
We use the information we collect to operate, maintain, and provide the features
|
||||
of the Service, to verify your identity, and to provide customer support.
|
||||
<p class="note">
|
||||
Note: This is a translation for convenience only. In case of discrepancies between
|
||||
the German and the English version, the German version shall prevail.
|
||||
</p>
|
||||
|
||||
<h2>3. Data Storage and Security</h2>
|
||||
<h2>1. Introduction</h2>
|
||||
<p>
|
||||
We implement security measures designed to protect your information from
|
||||
unauthorized access, disclosure, alteration, and destruction. We support
|
||||
end-to-end encryption, allowing you to encrypt your data on your device before it
|
||||
is sent to our servers.
|
||||
With this Privacy Policy, we inform you about the type, scope, and purpose of the
|
||||
processing of personal data ("Data") within the scope of using the service
|
||||
<strong>Super Productivity Sync</strong>. This policy also explains your rights
|
||||
under the General Data Protection Regulation (GDPR).
|
||||
</p>
|
||||
|
||||
<h2>4. Data Sharing</h2>
|
||||
<p>
|
||||
We do not share your personal information with third parties except as described
|
||||
in this privacy policy or with your consent.
|
||||
</p>
|
||||
|
||||
<h2>5. Data Retention</h2>
|
||||
<p>
|
||||
We retain your account information and sync data for as long as your account is
|
||||
active or as needed to provide you the Service. You may request deletion of your
|
||||
account and data at any time.
|
||||
</p>
|
||||
|
||||
<h2>6. Children's Privacy</h2>
|
||||
<p>
|
||||
Our Service is not directed to individuals under the age of 13. We do not
|
||||
knowingly collect personal information from children under 13.
|
||||
</p>
|
||||
|
||||
<h2>7. Changes to This Policy</h2>
|
||||
<p>
|
||||
We may update this Privacy Policy from time to time. We will notify you of any
|
||||
changes by posting the new Privacy Policy on this page.
|
||||
</p>
|
||||
|
||||
<h2>8. Data Controller</h2>
|
||||
<p>The data controller responsible for your personal data is:</p>
|
||||
<h2>2. Data Controller</h2>
|
||||
<address>
|
||||
{{ PRIVACY_CONTACT_NAME }}<br />
|
||||
{{ PRIVACY_ADDRESS_STREET }}<br />
|
||||
{{ PRIVACY_ADDRESS_CITY }}<br />
|
||||
{{ PRIVACY_ADDRESS_COUNTRY }}<br />
|
||||
<br />
|
||||
Email:
|
||||
<a href="mailto:{{ PRIVACY_CONTACT_EMAIL }}">{{ PRIVACY_CONTACT_EMAIL }}</a>
|
||||
</address>
|
||||
|
||||
<h2>9. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about this Privacy Policy, please contact us at
|
||||
<a href="mailto:{{ PRIVACY_CONTACT_EMAIL }}">{{ PRIVACY_CONTACT_EMAIL }}</a
|
||||
>.
|
||||
A Data Protection Officer has not been appointed as the statutory requirements for
|
||||
this are not met (fewer than 20 persons constantly involved in data processing).
|
||||
</p>
|
||||
|
||||
<h2>3. What Data We Process</h2>
|
||||
|
||||
<h3>(1) Inventory Data</h3>
|
||||
<ul>
|
||||
<li>Email address</li>
|
||||
<li>Password (stored exclusively as a cryptographic hash)</li>
|
||||
<li>Registration date</li>
|
||||
<li>Account status information (e.g., Active, Inactive)</li>
|
||||
</ul>
|
||||
|
||||
<h3>(2) Content Data</h3>
|
||||
<p>
|
||||
This includes all data you save in the "Super Productivity" app and synchronize
|
||||
via the Service:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Tasks</li>
|
||||
<li>Projects</li>
|
||||
<li>Notes</li>
|
||||
<li>Time tracking entries</li>
|
||||
<li>Settings</li>
|
||||
</ul>
|
||||
<p class="note">
|
||||
Note: If End-to-End Encryption (E2EE) is activated, this data exists on our server
|
||||
exclusively in encrypted form.
|
||||
</p>
|
||||
|
||||
<h3>(3) Meta and Log Data</h3>
|
||||
<p>Technically necessary when accessing the server:</p>
|
||||
<ul>
|
||||
<li>IP address</li>
|
||||
<li>Time of access</li>
|
||||
<li>App version / Browser type</li>
|
||||
<li>Operating system</li>
|
||||
<li>Error and diagnostic information</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Legal Basis for Processing</h2>
|
||||
<p>We process your data based on the following legal bases:</p>
|
||||
|
||||
<h3>(1) Performance of Contract (Art. 6(1)(b) GDPR)</h3>
|
||||
<ul>
|
||||
<li>Storage of your account</li>
|
||||
<li>Synchronization of your content</li>
|
||||
<li>Technical provision of the Service</li>
|
||||
<li>Sending security-relevant system emails (e.g., password reset)</li>
|
||||
</ul>
|
||||
|
||||
<h3>(2) Legitimate Interest (Art. 6(1)(f) GDPR)</h3>
|
||||
<ul>
|
||||
<li>Server and service security</li>
|
||||
<li>Detection and defense against misuse (DDoS, brute force attacks)</li>
|
||||
<li>Error analysis and stability improvement</li>
|
||||
</ul>
|
||||
|
||||
<h3>(3) Legal Obligations (Art. 6(1)(c) GDPR)</h3>
|
||||
<p>
|
||||
This applies to tax retention obligations for paid plans or official requests for
|
||||
information.
|
||||
</p>
|
||||
|
||||
<h2>5. Hosting and Infrastructure</h2>
|
||||
<p>The Service is hosted by:</p>
|
||||
<address>
|
||||
<strong>Alfahosting GmbH</strong><br />
|
||||
Ankerstraße 3b<br />
|
||||
06108 Halle (Saale)<br />
|
||||
Germany<br />
|
||||
Website: <a href="https://alfahosting.de/">https://alfahosting.de/</a>
|
||||
</address>
|
||||
<p>
|
||||
<strong>Data Location:</strong> Processing takes place exclusively on servers in
|
||||
Germany.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Data Processing Agreement:</strong> We have concluded a Data Processing
|
||||
Agreement (DPA) with Alfahosting GmbH in accordance with Art. 28 GDPR. No transfer
|
||||
to a third country takes place via the hoster.
|
||||
</p>
|
||||
|
||||
<h2>6. Data Processing during Synchronization</h2>
|
||||
|
||||
<h3>A) Standard Synchronization (without E2EE)</h3>
|
||||
<ul>
|
||||
<li>Your content data is transmitted via TLS/SSL transport encryption.</li>
|
||||
<li>
|
||||
It is stored in our database on the server. No end-to-end encryption is used
|
||||
here.
|
||||
</li>
|
||||
<li>
|
||||
Access by the Provider is technically possible but occurs exclusively if
|
||||
required for maintenance, diagnosis, or defense against technical disturbances.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>B) End-to-End Encryption (E2EE – optional)</h3>
|
||||
<p>If you enable E2EE in the app:</p>
|
||||
<ul>
|
||||
<li>Your data is encrypted locally on your device before transmission.</li>
|
||||
<li>The server stores only encrypted data blocks ("Blobs").</li>
|
||||
<li>
|
||||
We have <strong>no access</strong> to your keys and cannot restore, decrypt, or
|
||||
view the data.
|
||||
</li>
|
||||
<li>Loss of the key results in permanent data loss.</li>
|
||||
</ul>
|
||||
|
||||
<h2>7. Email Sending</h2>
|
||||
<p>
|
||||
We send exclusively transactional emails (e.g., password reset, email address
|
||||
confirmation, security-relevant system messages). Data processing is carried out
|
||||
based on Art. 6(1)(b) GDPR (Performance of Contract).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Service Provider:</strong> Emails are sent technically via the mail
|
||||
servers of our hosting provider <strong>Alfahosting GmbH</strong> (see Section 5).
|
||||
No external email marketing providers are used. The data thus remains within the
|
||||
German infrastructure.
|
||||
</p>
|
||||
|
||||
<h2>8. Storage Duration and Deletion</h2>
|
||||
|
||||
<h3>(1) Account Deletion</h3>
|
||||
<p>
|
||||
If you delete your account via the app settings, we will delete your inventory
|
||||
data and content data immediately, but no later than within
|
||||
<strong>7 days</strong> from all active systems.
|
||||
</p>
|
||||
|
||||
<h3>(2) Inactivity (Free Accounts)</h3>
|
||||
<p>
|
||||
We reserve the right to delete free accounts that have not been used for more than
|
||||
<strong>12 months</strong>. This will only occur after prior notification to the
|
||||
registered email address.
|
||||
</p>
|
||||
|
||||
<h3>(3) Server Log Files</h3>
|
||||
<p>
|
||||
Log data (IP addresses) are automatically deleted after
|
||||
<strong>7 to 14 days</strong>, unless security-relevant incidents require longer
|
||||
storage.
|
||||
</p>
|
||||
|
||||
<h3>(4) Statutory Retention Obligations</h3>
|
||||
<p>
|
||||
For paid accounts, we are obliged to retain invoice-relevant data for up to
|
||||
<strong>10 years</strong> in accordance with statutory requirements.
|
||||
</p>
|
||||
|
||||
<h2>9. Transfer to Third Parties</h2>
|
||||
<p>Data is generally not transferred to third parties unless:</p>
|
||||
<ul>
|
||||
<li>You have expressly consented (Art. 6(1)(a) GDPR),</li>
|
||||
<li>
|
||||
It is necessary for the performance of the contract (e.g., transfer to payment
|
||||
service providers for premium accounts),
|
||||
</li>
|
||||
<li>It serves the technical provision (see Hosting),</li>
|
||||
<li>Or we are legally obliged to do so (e.g., to law enforcement agencies).</li>
|
||||
</ul>
|
||||
<p>We <strong>never</strong> sell your data to third parties or advertisers.</p>
|
||||
|
||||
<h2>10. Your Rights</h2>
|
||||
<p>Under the GDPR, you have the following rights at any time:</p>
|
||||
<ul>
|
||||
<li><strong>Right of Access</strong> to your data stored by us (Art. 15 GDPR)</li>
|
||||
<li><strong>Right to Rectification</strong> of incorrect data (Art. 16 GDPR)</li>
|
||||
<li><strong>Right to Erasure</strong> of your data (Art. 17 GDPR)</li>
|
||||
<li><strong>Right to Restriction of Processing</strong> (Art. 18 GDPR)</li>
|
||||
<li>
|
||||
<strong>Right to Data Portability</strong> (export of your data) (Art. 20 GDPR)
|
||||
</li>
|
||||
<li><strong>Right to Object</strong> to processing (Art. 21 GDPR)</li>
|
||||
<li><strong>Right to Withdraw Consent</strong> (Art. 7(3) GDPR)</li>
|
||||
</ul>
|
||||
<p>
|
||||
To exercise your rights (e.g., deletion), a simple email is sufficient:
|
||||
<a href="mailto:{{ PRIVACY_CONTACT_EMAIL }}">{{ PRIVACY_CONTACT_EMAIL }}</a>
|
||||
</p>
|
||||
|
||||
<h2>11. Right to Lodge a Complaint</h2>
|
||||
<p>
|
||||
You have the right to lodge a complaint with a data protection supervisory
|
||||
authority. The authority responsible for us is:
|
||||
</p>
|
||||
<address>
|
||||
<strong
|
||||
>The Saxon Data Protection Commissioner (Sächsischer
|
||||
Datenschutzbeauftragter)</strong
|
||||
><br />
|
||||
Website:
|
||||
<a href="https://www.saechsdsb.de/">https://www.saechsdsb.de/</a>
|
||||
</address>
|
||||
|
||||
<h2>12. Cookies and Tracking</h2>
|
||||
<p>
|
||||
The SuperSync service uses only technically necessary session cookies for
|
||||
authentication. We do not use tracking cookies, analytics services, or advertising
|
||||
technologies.
|
||||
</p>
|
||||
|
||||
<h2>13. Automated Decision-Making</h2>
|
||||
<p>
|
||||
We do not use automated decision-making or profiling as defined by Art. 22 GDPR.
|
||||
</p>
|
||||
|
||||
<h2>14. Contact</h2>
|
||||
<p>If you have any questions about data protection, please contact us:</p>
|
||||
<p>
|
||||
Email:
|
||||
<a href="mailto:{{ PRIVACY_CONTACT_EMAIL }}">{{ PRIVACY_CONTACT_EMAIL }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@
|
|||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
.note {
|
||||
font-style: italic;
|
||||
color: var(--text-light);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -61,6 +66,10 @@
|
|||
>
|
||||
<h1>Terms of Service</h1>
|
||||
<p>Last updated: December 9, 2025</p>
|
||||
<p class="note">
|
||||
Note: This is a translation for convenience only. In case of discrepancies between
|
||||
the German and the English version, the German version shall prevail.
|
||||
</p>
|
||||
|
||||
<h2>1. Acceptance of Terms</h2>
|
||||
<p>
|
||||
|
|
@ -73,7 +82,9 @@
|
|||
<p>
|
||||
SuperSync is a data synchronization service designed to work with the Super
|
||||
Productivity application. It allows users to synchronize their task data across
|
||||
multiple devices.
|
||||
multiple devices. The Service is provided in its currently available version ("as
|
||||
available"). The Provider may further develop, modify, restrict, or discontinue
|
||||
the Service at any time.
|
||||
</p>
|
||||
|
||||
<h2>3. User Accounts</h2>
|
||||
|
|
@ -87,45 +98,134 @@
|
|||
to access the Service and for any activities or actions under your account.
|
||||
</p>
|
||||
|
||||
<h2>4. Data Privacy and Security</h2>
|
||||
<h2>4. Data Security and Encryption</h2>
|
||||
<p>
|
||||
Your use of the Service is also governed by our Privacy Policy. We take reasonable
|
||||
measures to protect your data, including end-to-end encryption support when
|
||||
enabled by the user.
|
||||
Your use of the Service is also governed by our Privacy Policy. Data transmission
|
||||
is encrypted via TLS/SSL. By default, data is stored without end-to-end
|
||||
encryption.
|
||||
</p>
|
||||
<p>
|
||||
You may optionally enable End-to-End Encryption (E2EE). If enabled, your
|
||||
encryption keys are generated and managed locally by you.
|
||||
<strong>Warning:</strong> We have no access to these keys and cannot recover
|
||||
encrypted data if you lose your key. Loss of your encryption key results in
|
||||
permanent data loss.
|
||||
</p>
|
||||
<p>
|
||||
Backups are performed on a best-effort basis. You are obligated to create regular
|
||||
local backup copies of your data.
|
||||
</p>
|
||||
|
||||
<h2>5. Future Pricing</h2>
|
||||
<h2>5. User Obligations</h2>
|
||||
<p>You agree:</p>
|
||||
<ul>
|
||||
<li>
|
||||
Not to misuse the Service (e.g., attacks, excessive load, circumvention of
|
||||
security mechanisms)
|
||||
</li>
|
||||
<li>
|
||||
Not to upload illegal content, malware, or third-party data without
|
||||
authorization
|
||||
</li>
|
||||
<li>
|
||||
To make the choice of security level (with/without E2EE) independently based on
|
||||
the sensitivity of your data
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you violate these Terms and the Provider is held liable by third parties as a
|
||||
result, you shall indemnify the Provider against all related claims.
|
||||
</p>
|
||||
|
||||
<h2>6. Future Pricing</h2>
|
||||
<p>
|
||||
The Service is currently provided free of charge. However, we reserve the right to
|
||||
introduce fees for the Service in the future. We will provide notice of any such
|
||||
changes before they become effective.
|
||||
</p>
|
||||
|
||||
<h2>6. Termination</h2>
|
||||
<h2>7. Termination</h2>
|
||||
<p>
|
||||
We may terminate or suspend your account immediately, without prior notice or
|
||||
liability, for any reason whatsoever, including without limitation if you breach
|
||||
the Terms.
|
||||
You may delete your account at any time via the app settings, thereby terminating
|
||||
the contract.
|
||||
</p>
|
||||
<p>
|
||||
For free services, we may terminate the contractual relationship with a notice
|
||||
period of two (2) weeks. We may terminate or suspend your account immediately
|
||||
without notice only for good cause (e.g., violation of these Terms, illegal
|
||||
activities).
|
||||
</p>
|
||||
<p>For paid services, the notice periods stated in the order process apply.</p>
|
||||
|
||||
<h2>8. Changes to Terms</h2>
|
||||
<p>
|
||||
We may amend these Terms if necessary to adapt to technical developments, changes
|
||||
in legal frameworks, new functions, security requirements, or business models.
|
||||
</p>
|
||||
<p>
|
||||
Amendments will be communicated to you at least
|
||||
<strong>six (6) weeks</strong> before they take effect. The notification will
|
||||
include your right to object and your right to terminate the contract. If you do
|
||||
not object within the notice period, the amendments are deemed accepted.
|
||||
</p>
|
||||
|
||||
<h2>7. Limitation of Liability</h2>
|
||||
<h2>9. Limitation of Liability</h2>
|
||||
<p>
|
||||
In no event shall SuperSync, nor its directors, employees, partners, agents,
|
||||
suppliers, or affiliates, be liable for any indirect, incidental, special,
|
||||
consequential or punitive damages, including without limitation, loss of profits,
|
||||
data, use, goodwill, or other intangible losses, resulting from your access to or
|
||||
use of or inability to access or use the Service.
|
||||
The Provider is liable without limitation in cases of intent, gross negligence,
|
||||
and culpable injury to life, body, or health.
|
||||
</p>
|
||||
<p>
|
||||
In cases of slight negligence, the Provider is only liable for the breach of
|
||||
essential contractual obligations. In these cases, liability is limited to the
|
||||
foreseeable damage typical for the contract.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Data Loss:</strong> Liability for data loss is limited to the effort that
|
||||
would have been required for recovery assuming proper, reasonable, and regular
|
||||
data backup by you. If you have not created sufficient backups, liability is
|
||||
excluded insofar as the damage would have been avoidable through backups.
|
||||
</p>
|
||||
<p>
|
||||
<strong>E2EE Data:</strong> The Provider is not liable for data loss, data
|
||||
corruption, or inaccessibility attributable to key loss, incorrect key management
|
||||
by you, or use of the optional E2EE function.
|
||||
</p>
|
||||
|
||||
<h2>8. Changes</h2>
|
||||
<h2>10. Right of Withdrawal for Consumers</h2>
|
||||
<p>
|
||||
We reserve the right, at our sole discretion, to modify or replace these Terms at
|
||||
any time. What constitutes a material change will be determined at our sole
|
||||
discretion.
|
||||
If you are a consumer and conclude a paid contract, you are entitled to a
|
||||
statutory right of withdrawal of <strong>14 days</strong>. Details are regulated
|
||||
in the separate cancellation policy provided during the order process.
|
||||
</p>
|
||||
|
||||
<h2>9. Contact Us</h2>
|
||||
<p>If you have any questions about these Terms, please contact us.</p>
|
||||
<h2>11. Applicable Law and Jurisdiction</h2>
|
||||
<p>
|
||||
The law of the Federal Republic of Germany applies, excluding the UN Sales
|
||||
Convention (CISG). If you are a merchant, a legal entity under public law, or a
|
||||
special fund under public law, Leipzig is the exclusive place of jurisdiction.
|
||||
Statutory places of jurisdiction apply to consumers.
|
||||
</p>
|
||||
|
||||
<h2>12. Online Dispute Resolution</h2>
|
||||
<p>
|
||||
Platform of the EU Commission for Online Dispute Resolution:
|
||||
<a
|
||||
href="https://ec.europa.eu/consumers/odr/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>https://ec.europa.eu/consumers/odr/</a
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
The Provider is not obligated and not willing to participate in dispute resolution
|
||||
proceedings before a consumer arbitration board.
|
||||
</p>
|
||||
|
||||
<h2>13. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about these Terms, please
|
||||
<a href="mailto:contact@super-productivity.com">contact us</a>.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@ const UploadSnapshotSchema = z.object({
|
|||
vectorClock: z.record(z.string(), z.number()),
|
||||
schemaVersion: z.number().optional(),
|
||||
isPayloadEncrypted: z.boolean().optional(),
|
||||
// Client's operation ID - server MUST use this to prevent ID mismatch bugs
|
||||
opId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
// Error helper
|
||||
|
|
@ -643,6 +645,7 @@ export const syncRoutes = async (fastify: FastifyInstance): Promise<void> => {
|
|||
vectorClock,
|
||||
schemaVersion,
|
||||
isPayloadEncrypted,
|
||||
opId,
|
||||
} = parseResult.data;
|
||||
const syncService = getSyncService();
|
||||
|
||||
|
|
@ -734,8 +737,10 @@ export const syncRoutes = async (fastify: FastifyInstance): Promise<void> => {
|
|||
|
||||
// Create a SYNC_IMPORT operation
|
||||
// Use the correct NgRx action type so the operation can be replayed on other clients
|
||||
// FIX: Use client's opId if provided to prevent ID mismatch bugs
|
||||
// When client doesn't send opId (legacy clients), fall back to server-generated UUID
|
||||
const op = {
|
||||
id: uuidv7(),
|
||||
id: opId ?? uuidv7(),
|
||||
clientId,
|
||||
actionType: '[SP_ALL] Load(import) all data',
|
||||
opType: 'SYNC_IMPORT' as const,
|
||||
|
|
|
|||
|
|
@ -516,6 +516,7 @@ export class MagicNavConfigService {
|
|||
if (result && result.title) {
|
||||
this._tagService.addTag({
|
||||
title: result.title,
|
||||
icon: result.icon,
|
||||
color: result.color,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,21 +12,28 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
// Use overflow: clip instead of hidden to prevent CDK overlay positioning issues (#5955)
|
||||
// overflow: clip clips content like hidden but doesn't affect overlay positioning
|
||||
overflow: clip;
|
||||
|
||||
// Hide additional buttons by default on desktop
|
||||
// Use visibility instead of display:none to keep element in layout flow
|
||||
// This ensures getBoundingClientRect() returns correct values for mat-menu positioning (#5955)
|
||||
.additional-btns {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
@include touchPrimaryDevice() {
|
||||
display: flex;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Show additional buttons on focus/hover (desktop)
|
||||
//&:focus-within .additional-btns,
|
||||
&:hover .additional-btns {
|
||||
display: flex;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// Folder header drop zone styling
|
||||
|
|
@ -50,6 +57,7 @@
|
|||
.additional-btns {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ import {
|
|||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { CommonModule, NgStyle } from '@angular/common';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TreeDndComponent } from '../../../ui/tree-dnd/tree.component';
|
||||
import { TreeNode } from '../../../ui/tree-dnd/tree.types';
|
||||
|
|
@ -70,6 +71,9 @@ export class NavListTreeComponent {
|
|||
// Access to service methods and data for visibility menu (includes Inbox for unhiding)
|
||||
readonly allUnarchivedProjects = this._navConfigService.allUnarchivedProjects;
|
||||
|
||||
// ViewChild for visibility menu trigger to close menu after toggling
|
||||
visibilityMenuTrigger = viewChild('visibilityBtn', { read: MatMenuTrigger });
|
||||
|
||||
readonly treeNodes = signal<TreeNode<MenuTreeViewNode>[]>([]);
|
||||
readonly treeKind = computed<MenuTreeKind>(() => this.item().treeKind);
|
||||
|
||||
|
|
@ -110,6 +114,8 @@ export class NavListTreeComponent {
|
|||
|
||||
toggleProjectVisibility(projectId: string): void {
|
||||
this._navConfigService.toggleProjectVisibility(projectId);
|
||||
// Close menu to prevent stale positioning after DOM update (#5955)
|
||||
this.visibilityMenuTrigger()?.closeMenu();
|
||||
}
|
||||
|
||||
onFolderMoreButton(event: MouseEvent, node: TreeNode<MenuTreeViewNode>): void {
|
||||
|
|
|
|||
|
|
@ -123,6 +123,12 @@ button.isActive2 {
|
|||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
// Scrolling support for many counters (fixes #5999)
|
||||
max-height: calc(100dvh - 120px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
padding-bottom: var(--s);
|
||||
|
||||
&.isVisible {
|
||||
pointer-events: all;
|
||||
|
|
@ -136,6 +142,7 @@ button.isActive2 {
|
|||
z-index: 2;
|
||||
margin-top: var(--s);
|
||||
margin-left: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
@for $i from 2 through 7 {
|
||||
&:nth-child(#{$i}) {
|
||||
|
|
|
|||
|
|
@ -39,16 +39,6 @@ export class DateTimeFormatService {
|
|||
}
|
||||
|
||||
constructor() {
|
||||
// Debug logging when locale changes
|
||||
const formatted = this._testFormats();
|
||||
console.group('[DateTimeFormat] Using locale:', this._locale() ?? 'default');
|
||||
console.log(
|
||||
` - Test time formating (13:00): ${formatted.time}.`,
|
||||
`format: ${formatted.time.includes('13') ? '24-hour' : '12-hour'}`,
|
||||
);
|
||||
console.log(' - Test date (31 december 2000):', formatted.date);
|
||||
console.groupEnd();
|
||||
|
||||
this._initMonkeyPatchFirstDayOfWeek();
|
||||
|
||||
// Use effect to reactively update date adapter locale when config changes
|
||||
|
|
|
|||
|
|
@ -538,8 +538,9 @@ export class ShareService {
|
|||
usedNative: true,
|
||||
target: 'native',
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError' || /Share canceled/i.test(error?.message)) {
|
||||
} catch (error: unknown) {
|
||||
const err = error as { name?: string; message?: string };
|
||||
if (err?.name === 'AbortError' || /Share canceled/i.test(err?.message ?? '')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Share cancelled',
|
||||
|
|
@ -600,8 +601,9 @@ export class ShareService {
|
|||
target: 'native',
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
} catch (error: unknown) {
|
||||
const err = error as { name?: string };
|
||||
if (err?.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Share cancelled',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import { TrackingReminderService } from '../../features/tracking-reminder/tracki
|
|||
import { of } from 'rxjs';
|
||||
import { signal } from '@angular/core';
|
||||
import { LS } from '../persistence/storage-keys.const';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { selectSyncConfig } from '../../features/config/store/global-config.reducer';
|
||||
import { selectEnabledIssueProviders } from '../../features/issue/store/issue-provider.selectors';
|
||||
|
||||
describe('StartupService', () => {
|
||||
let service: StartupService;
|
||||
|
|
@ -108,6 +111,12 @@ describe('StartupService', () => {
|
|||
},
|
||||
{ provide: ProjectService, useValue: projectServiceSpy },
|
||||
{ provide: TrackingReminderService, useValue: trackingReminderServiceSpy },
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{ selector: selectSyncConfig, value: { syncProvider: null } },
|
||||
{ selector: selectEnabledIssueProviders, value: [] },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@ import { isOnline$ } from '../../util/is-online';
|
|||
import { LS } from '../persistence/storage-keys.const';
|
||||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
import { DialogPleaseRateComponent } from '../../features/dialog-please-rate/dialog-please-rate.component';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectSyncConfig } from '../../features/config/store/global-config.reducer';
|
||||
import { selectEnabledIssueProviders } from '../../features/issue/store/issue-provider.selectors';
|
||||
import { LegacySyncProvider } from '../../imex/sync/legacy-sync-provider.model';
|
||||
import { GlobalConfigState } from '../../features/config/global-config.model';
|
||||
import { IPC } from '../../../../electron/shared-with-frontend/ipc-events.const';
|
||||
import { IpcRendererEvent } from 'electron';
|
||||
|
|
@ -51,6 +56,7 @@ export class StartupService {
|
|||
private _projectService = inject(ProjectService);
|
||||
private _trackingReminderService = inject(TrackingReminderService);
|
||||
private _opLogStore = inject(OperationLogStoreService);
|
||||
private _store = inject(Store);
|
||||
|
||||
constructor() {
|
||||
// Initialize electron error handler in an effect
|
||||
|
|
@ -235,8 +241,21 @@ export class StartupService {
|
|||
}
|
||||
|
||||
private _initOfflineBanner(): void {
|
||||
isOnline$.subscribe((isOnlineIn) => {
|
||||
if (!isOnlineIn) {
|
||||
const needsInternet$ = combineLatest([
|
||||
this._store.select(selectSyncConfig),
|
||||
this._store.select(selectEnabledIssueProviders),
|
||||
]).pipe(
|
||||
map(([syncConfig, enabledIssueProviders]) => {
|
||||
const hasCloudSync =
|
||||
syncConfig.syncProvider !== null &&
|
||||
syncConfig.syncProvider !== LegacySyncProvider.LocalFile;
|
||||
const hasIssueProviders = enabledIssueProviders.length > 0;
|
||||
return hasCloudSync || hasIssueProviders;
|
||||
}),
|
||||
);
|
||||
|
||||
combineLatest([isOnline$, needsInternet$]).subscribe(([isOnline, needsInternet]) => {
|
||||
if (!isOnline && needsInternet) {
|
||||
this._bannerService.open({
|
||||
id: BannerId.Offline,
|
||||
ico: 'cloud_off',
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ export const AVAILABLE_CUSTOM_THEMES: CustomTheme[] = [
|
|||
url: 'assets/themes/nord-snow-storm.css',
|
||||
requiredMode: 'light',
|
||||
},
|
||||
{
|
||||
id: 'catppuccin-mocha',
|
||||
name: 'Catppuccin Mocha',
|
||||
url: 'assets/themes/catppuccin-mocha.css',
|
||||
requiredMode: 'dark',
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export interface AndroidInterface {
|
|||
title: string,
|
||||
reminderType: string,
|
||||
triggerAtMs: number,
|
||||
useAlarmStyle: boolean,
|
||||
): void;
|
||||
cancelNativeReminder?(notificationId: number): void;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { createEffect } from '@ngrx/effects';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import { timer } from 'rxjs';
|
||||
import { combineLatest, timer } from 'rxjs';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
|
|
@ -12,6 +12,7 @@ import { TaskService } from '../../tasks/task.service';
|
|||
import { TaskAttachmentService } from '../../tasks/task-attachment/task-attachment.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectAllTasksWithReminder } from '../../tasks/store/task.selectors';
|
||||
import { selectReminderConfig } from '../../config/store/global-config.reducer';
|
||||
|
||||
// TODO send message to electron when current task changes here
|
||||
|
||||
|
|
@ -71,8 +72,13 @@ export class AndroidEffects {
|
|||
createEffect(
|
||||
() =>
|
||||
timer(DELAY_SCHEDULE).pipe(
|
||||
switchMap(() => this._store.select(selectAllTasksWithReminder)),
|
||||
tap(async (tasksWithReminders) => {
|
||||
switchMap(() =>
|
||||
combineLatest([
|
||||
this._store.select(selectAllTasksWithReminder),
|
||||
this._store.select(selectReminderConfig),
|
||||
]),
|
||||
),
|
||||
tap(async ([tasksWithReminders, reminderConfig]) => {
|
||||
try {
|
||||
const currentReminderIds = new Set(
|
||||
(tasksWithReminders || []).map((t) => t.id),
|
||||
|
|
@ -111,6 +117,8 @@ export class AndroidEffects {
|
|||
}
|
||||
await this._ensureExactAlarmAccess();
|
||||
|
||||
const useAlarmStyle = reminderConfig.useAlarmStyleReminders ?? false;
|
||||
|
||||
// Schedule each reminder using native Android AlarmManager
|
||||
for (const task of tasksWithReminders) {
|
||||
const id = generateNotificationId(task.id);
|
||||
|
|
@ -124,6 +132,7 @@ export class AndroidEffects {
|
|||
task.title,
|
||||
'TASK',
|
||||
scheduleAt,
|
||||
useAlarmStyle,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,18 +33,12 @@
|
|||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="width100">
|
||||
<input
|
||||
hidden="true"
|
||||
matInput
|
||||
/>
|
||||
<mat-slide-toggle
|
||||
matInput
|
||||
[checked]="this.config?.isIncreaseDoneSoundPitch"
|
||||
formControlName="isIncreaseDoneSoundPitch"
|
||||
>{{ T.GCF.SOUND.IS_INCREASE_DONE_PITCH | translate }}</mat-slide-toggle
|
||||
>
|
||||
</mat-form-field>
|
||||
<mat-slide-toggle
|
||||
class="width100"
|
||||
[checked]="this.config?.isIncreaseDoneSoundPitch"
|
||||
formControlName="isIncreaseDoneSoundPitch"
|
||||
>{{ T.GCF.SOUND.IS_INCREASE_DONE_PITCH | translate }}</mat-slide-toggle
|
||||
>
|
||||
<mat-form-field class="width100">
|
||||
<mat-label>{{ T.GCF.SOUND.BREAK_REMINDER_SOUND | translate }}</mat-label>
|
||||
<mat-select formControlName="breakReminderSound">
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
taskToggleDetailPanelOpen: 'I',
|
||||
taskOpenEstimationDialog: 'T',
|
||||
taskSchedule: 'S',
|
||||
taskUnschedule: 'U',
|
||||
taskToggleDone: 'D',
|
||||
taskAddSubTask: 'A',
|
||||
taskAddAttachment: 'L',
|
||||
|
|
@ -175,6 +176,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
countdownDuration: minute * 10,
|
||||
defaultTaskRemindOption: TaskReminderOptionId.AtStart, // The hard-coded default prior to this changeable setting
|
||||
isFocusWindow: false,
|
||||
useAlarmStyleReminders: false,
|
||||
},
|
||||
schedule: {
|
||||
isWorkStartEndEnabled: true,
|
||||
|
|
|
|||
|
|
@ -263,6 +263,13 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection<KeyboardConfig> = {
|
|||
label: T.GCF.KEYBOARD.TASK_SCHEDULE,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'taskUnschedule',
|
||||
type: 'keyboard',
|
||||
templateOptions: {
|
||||
label: T.GCF.KEYBOARD.TASK_UNSCHEDULE,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'taskToggleDone',
|
||||
type: 'keyboard',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ConfigFormSection, ReminderConfig } from '../global-config.model';
|
||||
import { TASK_REMINDER_OPTIONS } from '../../planner/dialog-schedule-task/task-reminder-options.const';
|
||||
import { T } from '../../../t.const';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
|
||||
export const REMINDER_FORM_CFG: ConfigFormSection<ReminderConfig> = {
|
||||
title: T.GCF.REMINDER.TITLE,
|
||||
|
|
@ -47,5 +48,17 @@ export const REMINDER_FORM_CFG: ConfigFormSection<ReminderConfig> = {
|
|||
label: T.GCF.REMINDER.IS_FOCUS_WINDOW,
|
||||
},
|
||||
},
|
||||
...(IS_ANDROID_WEB_VIEW
|
||||
? [
|
||||
{
|
||||
key: 'useAlarmStyleReminders' as const,
|
||||
type: 'checkbox',
|
||||
templateOptions: {
|
||||
label: T.GCF.REMINDER.USE_ALARM_STYLE_REMINDERS,
|
||||
description: T.GCF.REMINDER.USE_ALARM_STYLE_REMINDERS_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ export type ReminderConfig = Readonly<{
|
|||
defaultTaskRemindOption?: TaskReminderOptionId;
|
||||
disableReminders?: boolean;
|
||||
isFocusWindow?: boolean;
|
||||
// Android only: use alarm-style notifications (louder, more intrusive)
|
||||
useAlarmStyleReminders?: boolean;
|
||||
}>;
|
||||
|
||||
export type TrackingReminderConfigOld = Readonly<{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<input
|
||||
[ngModel]="formControl.value"
|
||||
(ngModelChange)="onInputValueChange($event)"
|
||||
(focus)="onFocus()"
|
||||
(paste)="onPaste($event)"
|
||||
[formlyAttributes]="field"
|
||||
[matAutocomplete]="auto"
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> implements
|
|||
filteredIcons = signal<string[]>([]);
|
||||
isEmoji = signal(false);
|
||||
private readonly _destroyRef = inject(DestroyRef);
|
||||
// Guards against duplicate processing when Windows emoji picker triggers multiple events
|
||||
private _lastSetValue: string | null = null;
|
||||
|
||||
protected readonly IS_ELECTRON = IS_ELECTRON;
|
||||
isLinux = IS_ELECTRON && window.ea.isLinux();
|
||||
|
|
@ -62,7 +64,27 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> implements
|
|||
return i;
|
||||
}
|
||||
|
||||
onFocus(): void {
|
||||
// Show initial icons when field is focused and no filter applied yet
|
||||
if (this.filteredIcons().length === 0) {
|
||||
const currentValue = this.formControl.value || '';
|
||||
if (currentValue) {
|
||||
// If there's a current value, filter by it
|
||||
this.onInputValueChange(currentValue);
|
||||
} else {
|
||||
// Show first 50 icons when empty
|
||||
this.filteredIcons.set(MATERIAL_ICONS.slice(0, 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onInputValueChange(val: string): void {
|
||||
// Skip if this is the value we just set programmatically (prevents double processing)
|
||||
if (val === this._lastSetValue) {
|
||||
this._lastSetValue = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const arr = MATERIAL_ICONS.filter(
|
||||
(icoStr) => icoStr && icoStr.toLowerCase().includes(val.toLowerCase()),
|
||||
);
|
||||
|
|
@ -75,13 +97,16 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> implements
|
|||
const firstEmoji = extractFirstEmoji(val);
|
||||
|
||||
if (firstEmoji) {
|
||||
this._lastSetValue = firstEmoji;
|
||||
this.formControl.setValue(firstEmoji);
|
||||
this.isEmoji.set(true);
|
||||
} else {
|
||||
this._lastSetValue = '';
|
||||
this.formControl.setValue('');
|
||||
this.isEmoji.set(false);
|
||||
}
|
||||
} else if (!val) {
|
||||
this._lastSetValue = '';
|
||||
this.formControl.setValue('');
|
||||
this.isEmoji.set(false);
|
||||
} else {
|
||||
|
|
@ -90,6 +115,7 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> implements
|
|||
}
|
||||
|
||||
onIconSelect(icon: string): void {
|
||||
this._lastSetValue = icon;
|
||||
this.formControl.setValue(icon);
|
||||
const emojiCheck = isSingleEmoji(icon);
|
||||
this.isEmoji.set(emojiCheck && !this.filteredIcons().includes(icon));
|
||||
|
|
@ -110,6 +136,7 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> implements
|
|||
const firstEmoji = extractFirstEmoji(pastedText);
|
||||
|
||||
if (firstEmoji && isSingleEmoji(firstEmoji)) {
|
||||
this._lastSetValue = firstEmoji;
|
||||
this.formControl.setValue(firstEmoji);
|
||||
this.isEmoji.set(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export type KeyboardConfig = Readonly<{
|
|||
taskOpenContextMenu?: string | null;
|
||||
taskDelete?: string | null;
|
||||
taskSchedule?: string | null;
|
||||
taskUnschedule?: string | null;
|
||||
selectPreviousTask?: string | null;
|
||||
selectNextTask?: string | null;
|
||||
moveTaskUp?: string | null;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@
|
|||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Pause/Resume button inside the circle (top center)
|
||||
.pause-resume-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.break-message {
|
||||
|
|
|
|||
|
|
@ -229,7 +229,12 @@
|
|||
<button
|
||||
mat-fab
|
||||
color="primary"
|
||||
[matTooltip]="T.F.FOCUS_MODE.START_FOCUS_SESSION | translate"
|
||||
[matTooltip]="
|
||||
isPlayButtonDisabled()
|
||||
? (T.F.FOCUS_MODE.SELECT_TASK_FIRST | translate)
|
||||
: (T.F.FOCUS_MODE.START_FOCUS_SESSION | translate)
|
||||
"
|
||||
[disabled]="isPlayButtonDisabled()"
|
||||
(click)="startSession()"
|
||||
class="play-button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -734,3 +734,225 @@ describe('FocusModeMainComponent - notes panel (issue #5752)', () => {
|
|||
expect(inlineMarkdown.componentInstance.model).toBe(existingNotes);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Separate test suite for isPlayButtonDisabled and startSession with sync tracking (issue #6009)
|
||||
* Uses signal-based mocks to properly test computed signals
|
||||
*/
|
||||
describe('FocusModeMainComponent - sync with tracking (issue #6009)', () => {
|
||||
let component: FocusModeMainComponent;
|
||||
let fixture: ComponentFixture<FocusModeMainComponent>;
|
||||
let mockStore: jasmine.SpyObj<Store>;
|
||||
let currentTaskSubject: BehaviorSubject<TaskCopy | null>;
|
||||
let focusModeConfigSignal: WritableSignal<any>;
|
||||
|
||||
const mockTask: TaskCopy = {
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
notes: 'Test notes',
|
||||
timeSpent: 0,
|
||||
timeEstimate: 0,
|
||||
created: Date.now(),
|
||||
isDone: false,
|
||||
subTaskIds: [],
|
||||
projectId: 'project-1',
|
||||
timeSpentOnDay: {},
|
||||
attachments: [],
|
||||
tagIds: [],
|
||||
} as TaskCopy;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create writable signal for focusModeConfig to test computed signals
|
||||
focusModeConfigSignal = signal({
|
||||
isSkipPreparation: false,
|
||||
isSyncSessionWithTracking: false,
|
||||
});
|
||||
|
||||
const storeSpy = jasmine.createSpyObj('Store', ['dispatch', 'select']);
|
||||
storeSpy.select.and.returnValue(of([]));
|
||||
|
||||
const globalConfigServiceSpy = jasmine.createSpyObj('GlobalConfigService', [], {
|
||||
misc: jasmine.createSpy().and.returnValue({
|
||||
taskNotesTpl: 'Default task notes template',
|
||||
}),
|
||||
});
|
||||
|
||||
currentTaskSubject = new BehaviorSubject<TaskCopy | null>(mockTask);
|
||||
const taskServiceSpy = jasmine.createSpyObj(
|
||||
'TaskService',
|
||||
['update', 'setCurrentId'],
|
||||
{
|
||||
currentTask$: currentTaskSubject.asObservable(),
|
||||
currentTaskId: jasmine.createSpy().and.returnValue(null),
|
||||
},
|
||||
);
|
||||
|
||||
const taskAttachmentServiceSpy = jasmine.createSpyObj('TaskAttachmentService', [
|
||||
'createFromDrop',
|
||||
]);
|
||||
|
||||
const issueServiceSpy = jasmine.createSpyObj('IssueService', ['issueLink']);
|
||||
issueServiceSpy.issueLink.and.returnValue(Promise.resolve('https://example.com'));
|
||||
|
||||
const simpleCounterServiceSpy = jasmine.createSpyObj('SimpleCounterService', [''], {
|
||||
enabledSimpleCounters$: of([]),
|
||||
});
|
||||
|
||||
const mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
|
||||
mockMatDialog.open.and.returnValue({
|
||||
afterClosed: () => of(null),
|
||||
} as MatDialogRef<any>);
|
||||
|
||||
// Use signals for properties that affect computed signals
|
||||
const focusModeServiceMock = {
|
||||
timeElapsed: signal(60000),
|
||||
isCountTimeDown: signal(true),
|
||||
progress: signal(0),
|
||||
timeRemaining: signal(1500000),
|
||||
isSessionRunning: signal(false),
|
||||
isSessionPaused: signal(false),
|
||||
isBreakActive: signal(false),
|
||||
currentCycle: signal(1),
|
||||
sessionDuration: signal(0),
|
||||
mode: signal(FocusModeMode.Pomodoro),
|
||||
mainState: signal(FocusMainUIState.Preparation),
|
||||
focusModeConfig: focusModeConfigSignal,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
FocusModeMainComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
EffectsModule.forRoot([]),
|
||||
MarkdownModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{ provide: GlobalConfigService, useValue: globalConfigServiceSpy },
|
||||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: TaskAttachmentService, useValue: taskAttachmentServiceSpy },
|
||||
{ provide: IssueService, useValue: issueServiceSpy },
|
||||
{ provide: SimpleCounterService, useValue: simpleCounterServiceSpy },
|
||||
{ provide: FocusModeService, useValue: focusModeServiceMock },
|
||||
{ provide: MatDialog, useValue: mockMatDialog },
|
||||
],
|
||||
})
|
||||
.overrideComponent(FocusModeMainComponent, {
|
||||
remove: { imports: [FocusModeTaskSelectorComponent] },
|
||||
add: { imports: [MockFocusModeTaskSelectorComponent] },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FocusModeMainComponent);
|
||||
component = fixture.componentInstance;
|
||||
mockStore = TestBed.inject(Store) as jasmine.SpyObj<Store>;
|
||||
fixture.detectChanges();
|
||||
mockStore.dispatch.calls.reset();
|
||||
});
|
||||
|
||||
describe('isPlayButtonDisabled', () => {
|
||||
it('should return true when sync with tracking is enabled and no task selected', () => {
|
||||
focusModeConfigSignal.set({
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: false,
|
||||
});
|
||||
currentTaskSubject.next(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isPlayButtonDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when sync with tracking is enabled and task is selected', () => {
|
||||
focusModeConfigSignal.set({
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: false,
|
||||
});
|
||||
currentTaskSubject.next(mockTask);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isPlayButtonDisabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when sync with tracking is disabled and no task selected', () => {
|
||||
focusModeConfigSignal.set({
|
||||
isSyncSessionWithTracking: false,
|
||||
isSkipPreparation: false,
|
||||
});
|
||||
currentTaskSubject.next(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isPlayButtonDisabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when sync with tracking is disabled and task is selected', () => {
|
||||
focusModeConfigSignal.set({
|
||||
isSyncSessionWithTracking: false,
|
||||
isSkipPreparation: false,
|
||||
});
|
||||
currentTaskSubject.next(mockTask);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isPlayButtonDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startSession with sync tracking', () => {
|
||||
it('should open task selector when sync is enabled and no task selected', () => {
|
||||
focusModeConfigSignal.set({
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: false,
|
||||
});
|
||||
currentTaskSubject.next(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.startSession();
|
||||
|
||||
expect(mockStore.dispatch).not.toHaveBeenCalled();
|
||||
expect(component.isTaskSelectorOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('should dispatch startFocusPreparation when sync is enabled and task is selected', () => {
|
||||
focusModeConfigSignal.set({
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: false,
|
||||
});
|
||||
currentTaskSubject.next(mockTask);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.startSession();
|
||||
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith(actions.startFocusPreparation());
|
||||
expect(component.isTaskSelectorOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should dispatch startFocusPreparation when sync is disabled and no task selected', () => {
|
||||
focusModeConfigSignal.set({
|
||||
isSyncSessionWithTracking: false,
|
||||
isSkipPreparation: false,
|
||||
});
|
||||
currentTaskSubject.next(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.startSession();
|
||||
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith(actions.startFocusPreparation());
|
||||
});
|
||||
|
||||
it('should dispatch startFocusSession when sync is enabled, task is selected, and skip preparation is enabled', () => {
|
||||
component.displayDuration.set(1500000);
|
||||
focusModeConfigSignal.set({
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: true,
|
||||
});
|
||||
currentTaskSubject.next(mockTask);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.startSession();
|
||||
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith(
|
||||
actions.startFocusSession({ duration: 1500000 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -176,6 +176,12 @@ export class FocusModeMainComponent {
|
|||
() => this._isInProgress() && this.mode() !== FocusModeMode.Flowtime,
|
||||
);
|
||||
|
||||
// Play button should be disabled when sync with tracking is enabled but no task is selected
|
||||
isPlayButtonDisabled = computed(() => {
|
||||
const config = this.focusModeConfig();
|
||||
return config?.isSyncSessionWithTracking && !this.currentTask();
|
||||
});
|
||||
|
||||
// Mode selector options
|
||||
readonly modeOptions: ReadonlyArray<SegmentedButtonOption> = [
|
||||
{
|
||||
|
|
@ -343,7 +349,15 @@ export class FocusModeMainComponent {
|
|||
}
|
||||
|
||||
startSession(): void {
|
||||
const shouldSkipPreparation = this.focusModeConfig()?.isSkipPreparation || false;
|
||||
const config = this.focusModeConfig();
|
||||
|
||||
// If sync with tracking is enabled, require a task to be selected
|
||||
if (config?.isSyncSessionWithTracking && !this.currentTask()) {
|
||||
this.openTaskSelector();
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSkipPreparation = config?.isSkipPreparation || false;
|
||||
if (shouldSkipPreparation) {
|
||||
const duration =
|
||||
this.mode() === FocusModeMode.Flowtime ? 0 : this.displayDuration();
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import {
|
|||
selectIsFocusModeEnabled,
|
||||
selectPomodoroConfig,
|
||||
} from '../../config/store/global-config.reducer';
|
||||
import { take, toArray } from 'rxjs/operators';
|
||||
import { skip, take, toArray } from 'rxjs/operators';
|
||||
|
||||
describe('FocusMode Bug #5875: Pomodoro timer sync issues', () => {
|
||||
let actions$: Observable<any>;
|
||||
|
|
@ -218,12 +218,40 @@ describe('FocusMode Bug #5875: Pomodoro timer sync issues', () => {
|
|||
|
||||
actions$ = of(actions.completeFocusSession({ isManual: true }));
|
||||
|
||||
effects.stopTrackingOnSessionEnd$.pipe(take(1)).subscribe((action) => {
|
||||
// Effect emits setPausedTaskId first (Bug #5737 fix), then unsetCurrentTask
|
||||
effects.stopTrackingOnSessionEnd$.pipe(skip(1), take(1)).subscribe((action) => {
|
||||
expect(action.type).toEqual(unsetCurrentTask.type);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch setPausedTaskId before unsetCurrentTask (Bug #5737 race condition fix)', (done) => {
|
||||
// Setup: Session is running, sync is enabled, task is being tracked
|
||||
store.overrideSelector(selectFocusModeConfig, {
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: false,
|
||||
isPauseTrackingDuringBreak: true,
|
||||
});
|
||||
currentTaskId$.next('task-123');
|
||||
store.refreshState();
|
||||
|
||||
actions$ = of(actions.completeFocusSession({ isManual: true }));
|
||||
|
||||
// Bug #5737 fix: Effect must emit setPausedTaskId BEFORE unsetCurrentTask
|
||||
// to store the task ID before it's cleared, enabling resume after break
|
||||
effects.stopTrackingOnSessionEnd$
|
||||
.pipe(take(2), toArray())
|
||||
.subscribe((actionsArr) => {
|
||||
expect(actionsArr.length).toBe(2);
|
||||
expect(actionsArr[0].type).toEqual(actions.setPausedTaskId.type);
|
||||
expect(
|
||||
(actionsArr[0] as ReturnType<typeof actions.setPausedTaskId>).pausedTaskId,
|
||||
).toEqual('task-123');
|
||||
expect(actionsArr[1].type).toEqual(unsetCurrentTask.type);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT dispatch unsetCurrentTask when session ends automatically in Pomodoro mode (break auto-starts)', (done) => {
|
||||
// Setup: Session completes automatically in Pomodoro mode
|
||||
// Break will auto-start, so autoStartBreakOnSessionComplete$ handles tracking pause
|
||||
|
|
@ -310,7 +338,8 @@ describe('FocusMode Bug #5875: Pomodoro timer sync issues', () => {
|
|||
|
||||
actions$ = of(actions.completeFocusSession({ isManual: false }));
|
||||
|
||||
effects.stopTrackingOnSessionEnd$.pipe(take(1)).subscribe((action) => {
|
||||
// Effect emits setPausedTaskId first (Bug #5737 fix), then unsetCurrentTask
|
||||
effects.stopTrackingOnSessionEnd$.pipe(skip(1), take(1)).subscribe((action) => {
|
||||
expect(action.type).toEqual(unsetCurrentTask.type);
|
||||
done();
|
||||
});
|
||||
|
|
@ -336,7 +365,8 @@ describe('FocusMode Bug #5875: Pomodoro timer sync issues', () => {
|
|||
|
||||
actions$ = of(actions.completeFocusSession({ isManual: false }));
|
||||
|
||||
effects.stopTrackingOnSessionEnd$.pipe(take(1)).subscribe((action) => {
|
||||
// Effect emits setPausedTaskId first (Bug #5737 fix), then unsetCurrentTask
|
||||
effects.stopTrackingOnSessionEnd$.pipe(skip(1), take(1)).subscribe((action) => {
|
||||
expect(action.type).toEqual(unsetCurrentTask.type);
|
||||
done();
|
||||
});
|
||||
|
|
@ -364,7 +394,8 @@ describe('FocusMode Bug #5875: Pomodoro timer sync issues', () => {
|
|||
|
||||
actions$ = of(actions.completeFocusSession({ isManual: false }));
|
||||
|
||||
effects.stopTrackingOnSessionEnd$.pipe(take(1)).subscribe((action) => {
|
||||
// Effect emits setPausedTaskId first (Bug #5737 fix), then unsetCurrentTask
|
||||
effects.stopTrackingOnSessionEnd$.pipe(skip(1), take(1)).subscribe((action) => {
|
||||
expect(action.type).toEqual(unsetCurrentTask.type);
|
||||
done();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -454,6 +454,33 @@ describe('FocusModeEffects', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should use cycle - 1 for break duration calculation (Bug #5737)', (done) => {
|
||||
// Bug #5737 fix: incrementCycle fires before autoStartBreakOnSessionComplete$
|
||||
// so we need to use cycle - 1 to get the correct session number for break calculation
|
||||
// When cycle=5 (after session 4 completes), break should be calculated for cycle=4
|
||||
actions$ = of(actions.completeFocusSession({ isManual: false }));
|
||||
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
|
||||
store.overrideSelector(selectors.selectCurrentCycle, 5); // After session 4 completes
|
||||
store.refreshState();
|
||||
|
||||
const getBreakDurationSpy = jasmine
|
||||
.createSpy('getBreakDuration')
|
||||
.and.returnValue({ duration: 15 * 60 * 1000, isLong: true });
|
||||
|
||||
strategyFactoryMock.getStrategy.and.returnValue({
|
||||
initialSessionDuration: 25 * 60 * 1000,
|
||||
shouldStartBreakAfterSession: true,
|
||||
shouldAutoStartNextSession: true,
|
||||
getBreakDuration: getBreakDurationSpy,
|
||||
});
|
||||
|
||||
effects.autoStartBreakOnSessionComplete$.pipe(toArray()).subscribe(() => {
|
||||
// Verify getBreakDuration was called with cycle - 1 = 4 (not 5)
|
||||
expect(getBreakDurationSpy).toHaveBeenCalledWith(4);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT dispatch when strategy.shouldStartBreakAfterSession is false', (done) => {
|
||||
actions$ = of(actions.completeFocusSession({ isManual: false }));
|
||||
store.overrideSelector(selectors.selectMode, FocusModeMode.Flowtime);
|
||||
|
|
@ -477,7 +504,7 @@ describe('FocusModeEffects', () => {
|
|||
});
|
||||
|
||||
describe('stopTrackingOnSessionEnd$', () => {
|
||||
it('should dispatch unsetCurrentTask when isManual=true AND isSyncSessionWithTracking=true AND isPauseTrackingDuringBreak=true AND currentTaskId exists', (done) => {
|
||||
it('should dispatch setPausedTaskId and unsetCurrentTask when isManual=true AND isSyncSessionWithTracking=true AND isPauseTrackingDuringBreak=true AND currentTaskId exists (Bug #5737)', (done) => {
|
||||
currentTaskId$.next('task-123');
|
||||
actions$ = of(actions.completeFocusSession({ isManual: true }));
|
||||
store.overrideSelector(selectFocusModeConfig, {
|
||||
|
|
@ -487,8 +514,14 @@ describe('FocusModeEffects', () => {
|
|||
});
|
||||
store.refreshState();
|
||||
|
||||
effects.stopTrackingOnSessionEnd$.pipe(take(1)).subscribe((action) => {
|
||||
expect(action).toEqual(unsetCurrentTask());
|
||||
// Bug #5737 fix: Should dispatch both setPausedTaskId and unsetCurrentTask
|
||||
// to preserve task for resumption after break
|
||||
effects.stopTrackingOnSessionEnd$.pipe(toArray()).subscribe((actionsArr) => {
|
||||
expect(actionsArr.length).toBe(2);
|
||||
expect(actionsArr[0]).toEqual(
|
||||
actions.setPausedTaskId({ pausedTaskId: 'task-123' }),
|
||||
);
|
||||
expect(actionsArr[1]).toEqual(unsetCurrentTask());
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
|
@ -561,8 +594,9 @@ describe('FocusModeEffects', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should dispatch unsetCurrentTask when isManual=false in Countdown mode (Bug #5996)', (done) => {
|
||||
it('should dispatch setPausedTaskId and unsetCurrentTask when isManual=false in Countdown mode (Bug #5996, #5737)', (done) => {
|
||||
// In Countdown mode, no break auto-starts, so this effect should fire
|
||||
// Bug #5737: Now also stores pausedTaskId for potential task resumption
|
||||
currentTaskId$.next('task-123');
|
||||
actions$ = of(actions.completeFocusSession({ isManual: false }));
|
||||
store.overrideSelector(selectFocusModeConfig, {
|
||||
|
|
@ -580,8 +614,12 @@ describe('FocusModeEffects', () => {
|
|||
getBreakDuration: () => null,
|
||||
});
|
||||
|
||||
effects.stopTrackingOnSessionEnd$.pipe(take(1)).subscribe((action) => {
|
||||
expect(action).toEqual(unsetCurrentTask());
|
||||
effects.stopTrackingOnSessionEnd$.pipe(toArray()).subscribe((actionsArr) => {
|
||||
expect(actionsArr.length).toBe(2);
|
||||
expect(actionsArr[0]).toEqual(
|
||||
actions.setPausedTaskId({ pausedTaskId: 'task-123' }),
|
||||
);
|
||||
expect(actionsArr[1]).toEqual(unsetCurrentTask());
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
|
@ -1685,6 +1723,33 @@ describe('FocusModeEffects', () => {
|
|||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should dispatch setCurrentTask when BREAK resumes with pausedTaskId', (done) => {
|
||||
store.overrideSelector(selectFocusModeConfig, {
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: false,
|
||||
});
|
||||
store.overrideSelector(
|
||||
selectors.selectTimer,
|
||||
createMockTimer({ isRunning: true, purpose: 'break' }),
|
||||
);
|
||||
store.overrideSelector(selectors.selectPausedTaskId, 'task-123');
|
||||
// Mock that the task exists
|
||||
store.overrideSelector(selectTaskById as any, {
|
||||
id: 'task-123',
|
||||
title: 'Test Task',
|
||||
});
|
||||
currentTaskId$.next(null); // No current task
|
||||
store.refreshState();
|
||||
|
||||
actions$ = of(actions.unPauseFocusSession());
|
||||
|
||||
effects.syncSessionResumeToTracking$.subscribe((action) => {
|
||||
expect(action.type).toEqual('[Task] SetCurrentTask');
|
||||
expect((action as any).id).toBe('task-123');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncSessionStartToTracking$', () => {
|
||||
|
|
@ -1782,7 +1847,7 @@ describe('FocusModeEffects', () => {
|
|||
}, 50);
|
||||
});
|
||||
|
||||
it('should NOT dispatch setCurrentTask when task no longer exists', (done) => {
|
||||
it('should dispatch showFocusOverlay when task no longer exists (Bug #5954)', (done) => {
|
||||
store.overrideSelector(selectFocusModeConfig, {
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: false,
|
||||
|
|
@ -1796,15 +1861,10 @@ describe('FocusModeEffects', () => {
|
|||
|
||||
actions$ = of(actions.startFocusSession({ duration: 25 * 60 * 1000 }));
|
||||
|
||||
let emitted = false;
|
||||
effects.syncSessionStartToTracking$.subscribe(() => {
|
||||
emitted = true;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
expect(emitted).toBe(false);
|
||||
effects.syncSessionStartToTracking$.subscribe((action) => {
|
||||
expect(action.type).toEqual('[FocusMode] Show Overlay');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to lastCurrentTask when no pausedTaskId (Bug #5954)', (done) => {
|
||||
|
|
@ -1836,7 +1896,7 @@ describe('FocusModeEffects', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should NOT dispatch when lastCurrentTask is done', (done) => {
|
||||
it('should dispatch showFocusOverlay when lastCurrentTask is done (Bug #5954)', (done) => {
|
||||
store.overrideSelector(selectFocusModeConfig, {
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: false,
|
||||
|
|
@ -1858,15 +1918,10 @@ describe('FocusModeEffects', () => {
|
|||
|
||||
actions$ = of(actions.startFocusSession({ duration: 25 * 60 * 1000 }));
|
||||
|
||||
let emitted = false;
|
||||
effects.syncSessionStartToTracking$.subscribe(() => {
|
||||
emitted = true;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
expect(emitted).toBe(false);
|
||||
effects.syncSessionStartToTracking$.subscribe((action) => {
|
||||
expect(action.type).toEqual('[FocusMode] Show Overlay');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2500,6 +2555,471 @@ describe('FocusModeEffects', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('_getTextButtonActions banner button behavior (issue #6000)', () => {
|
||||
let dispatchSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
|
||||
});
|
||||
|
||||
it('should have pause button when session is running', () => {
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 5 * 60 * 1000,
|
||||
isRunning: true,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false, // isOnBreak
|
||||
false, // isSessionCompleted
|
||||
false, // isBreakTimeUp
|
||||
{},
|
||||
);
|
||||
|
||||
expect(buttonActions.action).toBeDefined();
|
||||
expect(buttonActions.action.label).toBe('F.FOCUS_MODE.B.PAUSE');
|
||||
expect(buttonActions.action.icon).toBeUndefined(); // Text buttons have no icon
|
||||
});
|
||||
|
||||
it('should have resume button when session is paused', () => {
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 5 * 60 * 1000,
|
||||
isRunning: false,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false, // isOnBreak
|
||||
false, // isSessionCompleted
|
||||
false, // isBreakTimeUp
|
||||
{},
|
||||
);
|
||||
|
||||
expect(buttonActions.action).toBeDefined();
|
||||
expect(buttonActions.action.label).toBe('F.FOCUS_MODE.B.RESUME');
|
||||
});
|
||||
|
||||
it('should dispatch pauseFocusSession when pause button clicked', (done) => {
|
||||
taskServiceMock.currentTaskId = jasmine
|
||||
.createSpy('currentTaskId')
|
||||
.and.returnValue('task-123');
|
||||
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 5 * 60 * 1000,
|
||||
isRunning: true,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
|
||||
buttonActions.action.fn();
|
||||
|
||||
setTimeout(() => {
|
||||
const pauseCall = dispatchSpy.calls
|
||||
.all()
|
||||
.find((call) => call.args[0]?.type === actions.pauseFocusSession.type);
|
||||
expect(pauseCall).toBeDefined();
|
||||
expect(pauseCall?.args[0].pausedTaskId).toBe('task-123');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should dispatch unPauseFocusSession when resume button clicked', (done) => {
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 5 * 60 * 1000,
|
||||
isRunning: false,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
|
||||
buttonActions.action.fn();
|
||||
|
||||
setTimeout(() => {
|
||||
const resumeCall = dispatchSpy.calls
|
||||
.all()
|
||||
.find((call) => call.args[0]?.type === actions.unPauseFocusSession.type);
|
||||
expect(resumeCall).toBeDefined();
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should have end session button during work session', () => {
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 5 * 60 * 1000,
|
||||
isRunning: true,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false, // isOnBreak
|
||||
false, // isSessionCompleted
|
||||
false, // isBreakTimeUp
|
||||
{},
|
||||
);
|
||||
|
||||
expect(buttonActions.action2).toBeDefined();
|
||||
expect(buttonActions.action2.label).toBe('F.FOCUS_MODE.B.END_SESSION');
|
||||
});
|
||||
|
||||
it('should dispatch completeFocusSession when end session button clicked', (done) => {
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 5 * 60 * 1000,
|
||||
isRunning: true,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
|
||||
buttonActions.action2.fn();
|
||||
|
||||
setTimeout(() => {
|
||||
const completeCall = dispatchSpy.calls
|
||||
.all()
|
||||
.find((call) => call.args[0]?.type === actions.completeFocusSession.type);
|
||||
expect(completeCall).toBeDefined();
|
||||
expect(completeCall?.args[0].isManual).toBeTrue();
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should have skip break button during break', () => {
|
||||
const timer = createMockTimer({
|
||||
purpose: 'break',
|
||||
duration: 5 * 60 * 1000,
|
||||
elapsed: 2 * 60 * 1000,
|
||||
isRunning: true,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
true, // isOnBreak
|
||||
false, // isSessionCompleted
|
||||
false, // isBreakTimeUp
|
||||
{},
|
||||
);
|
||||
|
||||
expect(buttonActions.action2).toBeDefined();
|
||||
expect(buttonActions.action2.label).toBe('F.FOCUS_MODE.SKIP_BREAK');
|
||||
});
|
||||
|
||||
it('should dispatch skipBreak when skip break button clicked', (done) => {
|
||||
store.overrideSelector(selectors.selectPausedTaskId, 'paused-task-123');
|
||||
store.refreshState();
|
||||
|
||||
const timer = createMockTimer({
|
||||
purpose: 'break',
|
||||
duration: 5 * 60 * 1000,
|
||||
elapsed: 2 * 60 * 1000,
|
||||
isRunning: true,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
true, // isOnBreak
|
||||
false,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
|
||||
buttonActions.action2.fn();
|
||||
|
||||
setTimeout(() => {
|
||||
const skipBreakCall = dispatchSpy.calls
|
||||
.all()
|
||||
.find((call) => call.args[0]?.type === actions.skipBreak.type);
|
||||
expect(skipBreakCall).toBeDefined();
|
||||
expect(skipBreakCall?.args[0].pausedTaskId).toBe('paused-task-123');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should have start button when session is completed', () => {
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 25 * 60 * 1000,
|
||||
isRunning: false,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false, // isOnBreak
|
||||
true, // isSessionCompleted
|
||||
false, // isBreakTimeUp
|
||||
{},
|
||||
);
|
||||
|
||||
expect(buttonActions.action).toBeDefined();
|
||||
expect(buttonActions.action.label).toBe('F.FOCUS_MODE.B.START');
|
||||
// action2 should be undefined when start button is shown
|
||||
expect(buttonActions.action2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have start button when break time is up', () => {
|
||||
const timer = createMockTimer({
|
||||
purpose: 'break',
|
||||
duration: 5 * 60 * 1000,
|
||||
elapsed: 5 * 60 * 1000,
|
||||
isRunning: false,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
true, // isOnBreak
|
||||
false, // isSessionCompleted
|
||||
true, // isBreakTimeUp
|
||||
{},
|
||||
);
|
||||
|
||||
expect(buttonActions.action).toBeDefined();
|
||||
expect(buttonActions.action.label).toBe('F.FOCUS_MODE.B.START');
|
||||
expect(buttonActions.action2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should always have to focus overlay button', () => {
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 5 * 60 * 1000,
|
||||
isRunning: true,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(buttonActions.action3).toBeDefined();
|
||||
expect(buttonActions.action3.label).toBe('F.FOCUS_MODE.B.TO_FOCUS_OVERLAY');
|
||||
});
|
||||
|
||||
it('should dispatch showFocusOverlay when to focus overlay button clicked', (done) => {
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 5 * 60 * 1000,
|
||||
isRunning: true,
|
||||
});
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
|
||||
buttonActions.action3.fn();
|
||||
|
||||
setTimeout(() => {
|
||||
const overlayCall = dispatchSpy.calls
|
||||
.all()
|
||||
.find((call) => call.args[0]?.type === '[FocusMode] Show Overlay');
|
||||
expect(overlayCall).toBeDefined();
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should dispatch startBreak when session completed with isManualBreakStart=true in Pomodoro mode', (done) => {
|
||||
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
|
||||
store.overrideSelector(selectors.selectCurrentCycle, 1);
|
||||
store.refreshState();
|
||||
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 25 * 60 * 1000,
|
||||
});
|
||||
const focusModeConfig = {
|
||||
isManualBreakStart: true,
|
||||
isPauseTrackingDuringBreak: false,
|
||||
};
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false, // isOnBreak
|
||||
true, // isSessionCompleted
|
||||
false, // isBreakTimeUp
|
||||
focusModeConfig,
|
||||
);
|
||||
|
||||
buttonActions.action.fn();
|
||||
|
||||
setTimeout(() => {
|
||||
const startBreakCall = dispatchSpy.calls
|
||||
.all()
|
||||
.find((call) => call.args[0]?.type === actions.startBreak.type);
|
||||
expect(startBreakCall).toBeDefined();
|
||||
expect(startBreakCall?.args[0].duration).toBe(5 * 60 * 1000);
|
||||
expect(startBreakCall?.args[0].isLongBreak).toBeFalse();
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// Bug #5737: Manual break start should use cycle - 1 for break duration calculation
|
||||
it('should dispatch long break when cycle=5 with manual break start (Bug #5737)', (done) => {
|
||||
// After session 4 completes, incrementCycleOnSessionComplete$ runs first,
|
||||
// setting cycle to 5. When user manually clicks start, we should use cycle - 1 = 4
|
||||
// to correctly trigger the long break (every 4th session).
|
||||
const getBreakDurationSpy = jasmine
|
||||
.createSpy('getBreakDuration')
|
||||
.and.callFake((cycle: number) => {
|
||||
// Return long break for cycle 4 (every 4th session)
|
||||
const isLong = cycle % 4 === 0;
|
||||
return {
|
||||
duration: isLong ? 15 * 60 * 1000 : 5 * 60 * 1000,
|
||||
isLong,
|
||||
};
|
||||
});
|
||||
strategyFactoryMock.getStrategy.and.returnValue({
|
||||
shouldStartBreakAfterSession: true,
|
||||
shouldAutoStartNextSession: true,
|
||||
initialSessionDuration: 25 * 60 * 1000,
|
||||
getBreakDuration: getBreakDurationSpy,
|
||||
});
|
||||
|
||||
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
|
||||
store.overrideSelector(selectors.selectCurrentCycle, 5); // Already incremented
|
||||
store.refreshState();
|
||||
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 25 * 60 * 1000,
|
||||
});
|
||||
const focusModeConfig = {
|
||||
isManualBreakStart: true,
|
||||
isPauseTrackingDuringBreak: false,
|
||||
};
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false, // isOnBreak
|
||||
true, // isSessionCompleted
|
||||
false, // isBreakTimeUp
|
||||
focusModeConfig,
|
||||
);
|
||||
|
||||
buttonActions.action.fn();
|
||||
|
||||
setTimeout(() => {
|
||||
// Verify getBreakDuration was called with cycle - 1 = 4 (not 5)
|
||||
expect(getBreakDurationSpy).toHaveBeenCalledWith(4);
|
||||
|
||||
const startBreakCall = dispatchSpy.calls
|
||||
.all()
|
||||
.find((call) => call.args[0]?.type === actions.startBreak.type);
|
||||
expect(startBreakCall).toBeDefined();
|
||||
// Long break should be 15 minutes (default), not 5 minutes (short break)
|
||||
expect(startBreakCall?.args[0].duration).toBe(15 * 60 * 1000);
|
||||
expect(startBreakCall?.args[0].isLongBreak).toBeTrue();
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should dispatch startFocusSession when session completed with isManualBreakStart=false', (done) => {
|
||||
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
|
||||
store.overrideSelector(selectors.selectCurrentCycle, 1);
|
||||
store.refreshState();
|
||||
|
||||
const timer = createMockTimer({
|
||||
purpose: 'work',
|
||||
duration: 25 * 60 * 1000,
|
||||
elapsed: 25 * 60 * 1000,
|
||||
});
|
||||
const focusModeConfig = {
|
||||
isManualBreakStart: false,
|
||||
};
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
focusModeConfig,
|
||||
);
|
||||
|
||||
buttonActions.action.fn();
|
||||
|
||||
setTimeout(() => {
|
||||
const startSessionCall = dispatchSpy.calls
|
||||
.all()
|
||||
.find((call) => call.args[0]?.type === actions.startFocusSession.type);
|
||||
const startBreakCall = dispatchSpy.calls
|
||||
.all()
|
||||
.find((call) => call.args[0]?.type === actions.startBreak.type);
|
||||
expect(startSessionCall).toBeDefined();
|
||||
expect(startBreakCall).toBeUndefined();
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should handle isBreakTimeUp case correctly', (done) => {
|
||||
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
|
||||
store.overrideSelector(selectors.selectPausedTaskId, null);
|
||||
store.refreshState();
|
||||
|
||||
const timer = createMockTimer({
|
||||
purpose: 'break',
|
||||
duration: 5 * 60 * 1000,
|
||||
elapsed: 5 * 60 * 1000,
|
||||
isRunning: false,
|
||||
});
|
||||
const focusModeConfig = {
|
||||
isManualBreakStart: true,
|
||||
};
|
||||
|
||||
const buttonActions = (effects as any)._getTextButtonActions(
|
||||
timer,
|
||||
true, // isOnBreak
|
||||
false, // isSessionCompleted
|
||||
true, // isBreakTimeUp
|
||||
focusModeConfig,
|
||||
);
|
||||
|
||||
buttonActions.action.fn();
|
||||
|
||||
setTimeout(() => {
|
||||
const skipBreakCall = dispatchSpy.calls
|
||||
.all()
|
||||
.find((call) => call.args[0]?.type === actions.skipBreak.type);
|
||||
expect(skipBreakCall).toBeDefined();
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storePausedTaskOnManualBreakSession$ (Bug #5954)', () => {
|
||||
it('should dispatch setPausedTaskId when session completes with manual break start and pause tracking enabled', (done) => {
|
||||
actions$ = of(actions.completeFocusSession({ isManual: false }));
|
||||
|
|
@ -2540,7 +3060,9 @@ describe('FocusModeEffects', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should NOT dispatch setPausedTaskId when isPauseTrackingDuringBreak is false', (done) => {
|
||||
// Bug #5974 fix: Store pausedTaskId even when isPauseTrackingDuringBreak is false
|
||||
// This allows tracking to resume if user manually stops tracking before starting break
|
||||
it('should dispatch setPausedTaskId when isPauseTrackingDuringBreak is false', (done) => {
|
||||
actions$ = of(actions.completeFocusSession({ isManual: false }));
|
||||
store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro);
|
||||
store.overrideSelector(selectFocusModeConfig, {
|
||||
|
|
@ -2552,12 +3074,11 @@ describe('FocusModeEffects', () => {
|
|||
currentTaskId$.next('task-123');
|
||||
store.refreshState();
|
||||
|
||||
effects.storePausedTaskOnManualBreakSession$
|
||||
.pipe(toArray())
|
||||
.subscribe((actionsArr) => {
|
||||
expect(actionsArr.length).toBe(0);
|
||||
done();
|
||||
});
|
||||
effects.storePausedTaskOnManualBreakSession$.pipe(take(1)).subscribe((action) => {
|
||||
expect(action.type).toEqual(actions.setPausedTaskId.type);
|
||||
expect(action.pausedTaskId).toBe('task-123');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT dispatch setPausedTaskId when no current task', (done) => {
|
||||
|
|
@ -2641,7 +3162,7 @@ describe('FocusModeEffects', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should NOT dispatch when lastCurrentTask no longer exists in store', (done) => {
|
||||
it('should dispatch showFocusOverlay when lastCurrentTask no longer exists in store (Bug #5954)', (done) => {
|
||||
store.overrideSelector(selectFocusModeConfig, {
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: false,
|
||||
|
|
@ -2659,15 +3180,10 @@ describe('FocusModeEffects', () => {
|
|||
|
||||
actions$ = of(actions.startFocusSession({ duration: 25 * 60 * 1000 }));
|
||||
|
||||
let emitted = false;
|
||||
effects.syncSessionStartToTracking$.subscribe(() => {
|
||||
emitted = true;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
expect(emitted).toBe(false);
|
||||
effects.syncSessionStartToTracking$.subscribe((action) => {
|
||||
expect(action.type).toEqual('[FocusMode] Show Overlay');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2733,7 +3249,7 @@ describe('FocusModeEffects', () => {
|
|||
});
|
||||
|
||||
describe('stopTrackingOnSessionEnd$ edge cases', () => {
|
||||
it('should respect isPauseTrackingDuringBreak=true for manual session end', (done) => {
|
||||
it('should respect isPauseTrackingDuringBreak=true for manual session end and store pausedTaskId (Bug #5737)', (done) => {
|
||||
store.overrideSelector(selectFocusModeConfig, {
|
||||
isSyncSessionWithTracking: true,
|
||||
isSkipPreparation: false,
|
||||
|
|
@ -2744,8 +3260,11 @@ describe('FocusModeEffects', () => {
|
|||
|
||||
actions$ = of(actions.completeFocusSession({ isManual: true }));
|
||||
|
||||
effects.stopTrackingOnSessionEnd$.pipe(take(1)).subscribe((action) => {
|
||||
expect(action.type).toEqual('[Task] UnsetCurrentTask');
|
||||
// Bug #5737: Now dispatches both setPausedTaskId and unsetCurrentTask
|
||||
effects.stopTrackingOnSessionEnd$.pipe(toArray()).subscribe((actionsArr) => {
|
||||
expect(actionsArr.length).toBe(2);
|
||||
expect(actionsArr[0].type).toEqual('[FocusMode] Set Paused Task Id');
|
||||
expect(actionsArr[1].type).toEqual('[Task] UnsetCurrentTask');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
withLatestFrom,
|
||||
} from 'rxjs/operators';
|
||||
import * as actions from './focus-mode.actions';
|
||||
import { cancelFocusSession, showFocusOverlay } from './focus-mode.actions';
|
||||
import { showFocusOverlay } from './focus-mode.actions';
|
||||
import * as selectors from './focus-mode.selectors';
|
||||
import { FocusModeStrategyFactory } from '../focus-mode-strategies';
|
||||
import { GlobalConfigService } from '../../config/global-config.service';
|
||||
|
|
@ -184,7 +184,7 @@ export class FocusModeEffects {
|
|||
filter(
|
||||
([_action, cfg, timer, pausedTaskId, currentTaskId]) =>
|
||||
!!cfg?.isSyncSessionWithTracking &&
|
||||
timer.purpose === 'work' &&
|
||||
(timer.purpose === 'work' || timer.purpose === 'break') &&
|
||||
!currentTaskId &&
|
||||
!!pausedTaskId,
|
||||
),
|
||||
|
|
@ -201,6 +201,7 @@ export class FocusModeEffects {
|
|||
// Sync: When focus session starts → start tracking (if not already tracking)
|
||||
// Checks that the paused task still exists before starting tracking
|
||||
// Bug #5954 fix: Falls back to lastCurrentTask if no pausedTaskId (e.g., after app restart)
|
||||
// Bug #5954 fix: Shows focus overlay if no valid (undone) task is available
|
||||
syncSessionStartToTracking$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(actions.startFocusSession),
|
||||
|
|
@ -224,11 +225,12 @@ export class FocusModeEffects {
|
|||
return this.store.select(selectTaskById, { id: taskIdToResume }).pipe(
|
||||
take(1),
|
||||
map((task) =>
|
||||
task && !task.isDone ? setCurrentTask({ id: taskIdToResume }) : null,
|
||||
task && !task.isDone
|
||||
? setCurrentTask({ id: taskIdToResume })
|
||||
: actions.showFocusOverlay(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
filter((action): action is ReturnType<typeof setCurrentTask> => action !== null),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -293,6 +295,7 @@ export class FocusModeEffects {
|
|||
// Bug #5875 fix: Stop tracking on manual session end
|
||||
// Bug #5954 fix: Only stop tracking if isPauseTrackingDuringBreak is enabled
|
||||
// Bug #5996 fix: Also stop tracking on automatic completion for modes without auto-break
|
||||
// Bug #5737 fix: Store pausedTaskId before unsetting to avoid race condition
|
||||
stopTrackingOnSessionEnd$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(actions.completeFocusSession),
|
||||
|
|
@ -319,7 +322,11 @@ export class FocusModeEffects {
|
|||
strategy.shouldStartBreakAfterSession && !config?.isManualBreakStart;
|
||||
return !breakWillAutoStart;
|
||||
}),
|
||||
map(() => unsetCurrentTask()),
|
||||
// Bug #5737 fix: Store pausedTaskId before unsetting current task
|
||||
// This ensures the task can be resumed after break even with manual "End Session"
|
||||
switchMap(([_action, _config, _mode, taskId]) =>
|
||||
of(actions.setPausedTaskId({ pausedTaskId: taskId }), unsetCurrentTask()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -339,7 +346,10 @@ export class FocusModeEffects {
|
|||
}),
|
||||
switchMap(([_, mode, cycle, config, currentTaskId]) => {
|
||||
const strategy = this.strategyFactory.getStrategy(mode);
|
||||
const breakInfo = strategy.getBreakDuration(cycle);
|
||||
// Bug #5737 fix: Use cycle - 1 since incrementCycle fires before this effect
|
||||
// This ensures long break occurs after session 4, not session 3
|
||||
const actualCycle = Math.max(1, (cycle || 1) - 1);
|
||||
const breakInfo = strategy.getBreakDuration(actualCycle);
|
||||
const shouldPauseTracking = config?.isPauseTrackingDuringBreak && currentTaskId;
|
||||
const actionsArr: any[] = [];
|
||||
|
||||
|
|
@ -383,6 +393,8 @@ export class FocusModeEffects {
|
|||
|
||||
// Effect 5: Store pausedTaskId when session completes with manual break start
|
||||
// Bug #5954 fix: Ensures task can be resumed when break is skipped/completed
|
||||
// Bug #5974 fix: Store pausedTaskId regardless of isPauseTrackingDuringBreak setting
|
||||
// This allows tracking to resume when user manually stops tracking before starting break
|
||||
storePausedTaskOnManualBreakSession$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(actions.completeFocusSession),
|
||||
|
|
@ -393,11 +405,14 @@ export class FocusModeEffects {
|
|||
),
|
||||
filter(([_, mode, config, currentTaskId]) => {
|
||||
const strategy = this.strategyFactory.getStrategy(mode);
|
||||
// Only when manual break is enabled, pause tracking is enabled, and there's a current task
|
||||
// Store pausedTaskId when manual break is enabled and there's a current task
|
||||
// Note: We store regardless of isPauseTrackingDuringBreak because:
|
||||
// - If isPauseTrackingDuringBreak=true: pausedTaskId is used to resume after break
|
||||
// - If isPauseTrackingDuringBreak=false: pausedTaskId is used to resume if user
|
||||
// manually stopped tracking before starting the break (bug #5974)
|
||||
return (
|
||||
strategy.shouldStartBreakAfterSession &&
|
||||
!!config?.isManualBreakStart &&
|
||||
!!config?.isPauseTrackingDuringBreak &&
|
||||
!!currentTaskId
|
||||
);
|
||||
}),
|
||||
|
|
@ -676,13 +691,10 @@ export class FocusModeEffects {
|
|||
return;
|
||||
}
|
||||
|
||||
// In background mode, also show banner when paused
|
||||
// Show banner when paused so user can resume from banner
|
||||
const useIconButtons = focusModeConfig?.isStartInBackground;
|
||||
const shouldShowBanner =
|
||||
isSessionRunning ||
|
||||
isOnBreak ||
|
||||
isSessionCompleted ||
|
||||
(useIconButtons && isSessionPaused);
|
||||
isSessionRunning || isOnBreak || isSessionCompleted || isSessionPaused;
|
||||
|
||||
// Check if break time is up (needed for both banner display and button actions)
|
||||
const isBreakTimeUp =
|
||||
|
|
@ -764,7 +776,13 @@ export class FocusModeEffects {
|
|||
isBreakTimeUp,
|
||||
focusModeConfig,
|
||||
)
|
||||
: this._getTextButtonActions(isSessionCompleted)),
|
||||
: this._getTextButtonActions(
|
||||
timer,
|
||||
isOnBreak,
|
||||
isSessionCompleted,
|
||||
isBreakTimeUp,
|
||||
focusModeConfig,
|
||||
)),
|
||||
});
|
||||
} else {
|
||||
this.bannerService.dismiss(BannerId.FocusMode);
|
||||
|
|
@ -776,26 +794,154 @@ export class FocusModeEffects {
|
|||
);
|
||||
|
||||
private _getTextButtonActions(
|
||||
timer: TimerState,
|
||||
isOnBreak: boolean,
|
||||
isSessionCompleted: boolean,
|
||||
isBreakTimeUp: boolean,
|
||||
focusModeConfig: FocusModeConfig | undefined,
|
||||
): Pick<Banner, 'action' | 'action2' | 'action3'> {
|
||||
return {
|
||||
action2: {
|
||||
label: T.F.FOCUS_MODE.B.TO_FOCUS_OVERLAY,
|
||||
fn: () => {
|
||||
this.store.dispatch(showFocusOverlay());
|
||||
},
|
||||
},
|
||||
// Only show Cancel button when session is not completed
|
||||
...(isSessionCompleted
|
||||
? {}
|
||||
: {
|
||||
action: {
|
||||
label: T.G.CANCEL,
|
||||
fn: () => {
|
||||
this.store.dispatch(cancelFocusSession());
|
||||
},
|
||||
const isPaused = !timer.isRunning && timer.purpose !== null;
|
||||
|
||||
// Show "Start" button when session completed OR break time is up
|
||||
// Otherwise show play/pause button
|
||||
const shouldShowStartButton = isSessionCompleted || isBreakTimeUp;
|
||||
|
||||
const playPauseAction = shouldShowStartButton
|
||||
? {
|
||||
label: T.F.FOCUS_MODE.B.START,
|
||||
fn: () => {
|
||||
// When starting from break completion, first properly complete/skip the break
|
||||
// to resume task tracking and clean up state
|
||||
if (isBreakTimeUp) {
|
||||
combineLatest([
|
||||
this.store.select(selectors.selectMode),
|
||||
this.store.select(selectors.selectPausedTaskId),
|
||||
])
|
||||
.pipe(take(1))
|
||||
.subscribe(([mode, pausedTaskId]) => {
|
||||
const strategy = this.strategyFactory.getStrategy(mode);
|
||||
// Skip break (with pausedTaskId to resume tracking)
|
||||
this.store.dispatch(actions.skipBreak({ pausedTaskId }));
|
||||
// Only manually start session if strategy doesn't auto-start
|
||||
// (Pomodoro auto-starts via skipBreak$ effect)
|
||||
if (!strategy.shouldAutoStartNextSession) {
|
||||
this.store.dispatch(
|
||||
actions.startFocusSession({
|
||||
duration: strategy.initialSessionDuration,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Session completed - check if we should start a break or new session
|
||||
combineLatest([
|
||||
this.store.select(selectors.selectMode),
|
||||
this.store.select(selectors.selectCurrentCycle),
|
||||
this.store.select(selectors.selectPausedTaskId),
|
||||
])
|
||||
.pipe(take(1))
|
||||
.subscribe(([mode, cycle, pausedTaskId]) => {
|
||||
const strategy = this.strategyFactory.getStrategy(mode);
|
||||
|
||||
// If manual break start is enabled and mode supports breaks, start a break
|
||||
if (
|
||||
focusModeConfig?.isManualBreakStart &&
|
||||
strategy.shouldStartBreakAfterSession
|
||||
) {
|
||||
// Bug #5737 fix: Use cycle - 1 since incrementCycle fires before user clicks
|
||||
// This ensures long break occurs after session 4, not session 5
|
||||
const actualCycle = Math.max(1, (cycle || 1) - 1);
|
||||
const breakInfo = strategy.getBreakDuration(actualCycle);
|
||||
if (breakInfo) {
|
||||
const currentTaskId = this.taskService.currentTaskId();
|
||||
const shouldPauseTracking =
|
||||
focusModeConfig?.isPauseTrackingDuringBreak && currentTaskId;
|
||||
|
||||
if (shouldPauseTracking) {
|
||||
this.store.dispatch(unsetCurrentTask());
|
||||
}
|
||||
|
||||
// Bug #5974 fix: If isPauseTrackingDuringBreak is false and user manually
|
||||
// stopped tracking (pausedTaskId exists), resume tracking during break
|
||||
const shouldResumeTracking =
|
||||
!focusModeConfig?.isPauseTrackingDuringBreak &&
|
||||
!currentTaskId &&
|
||||
pausedTaskId;
|
||||
|
||||
if (shouldResumeTracking) {
|
||||
this.store.dispatch(setCurrentTask({ id: pausedTaskId }));
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
actions.startBreak({
|
||||
duration: breakInfo.duration,
|
||||
isLongBreak: breakInfo.isLong,
|
||||
pausedTaskId: shouldPauseTracking ? currentTaskId : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Otherwise start a new session
|
||||
this.store.dispatch(
|
||||
actions.startFocusSession({
|
||||
duration: strategy.initialSessionDuration,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
: {
|
||||
label: isPaused ? T.F.FOCUS_MODE.B.RESUME : T.F.FOCUS_MODE.B.PAUSE,
|
||||
fn: () => {
|
||||
if (isPaused) {
|
||||
this.store.dispatch(actions.unPauseFocusSession());
|
||||
} else {
|
||||
// Pass current task ID so it can be restored on resume
|
||||
const currentTaskId = this.taskService.currentTaskId();
|
||||
this.store.dispatch(
|
||||
actions.pauseFocusSession({ pausedTaskId: currentTaskId }),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// End session button - complete for work, skip for break (while running)
|
||||
// Hide when session is completed or break time is up (Start button takes priority)
|
||||
const endAction = shouldShowStartButton
|
||||
? undefined
|
||||
: isOnBreak
|
||||
? {
|
||||
label: T.F.FOCUS_MODE.SKIP_BREAK,
|
||||
fn: () => {
|
||||
this.store
|
||||
.select(selectors.selectPausedTaskId)
|
||||
.pipe(take(1))
|
||||
.subscribe((pausedTaskId) => {
|
||||
this.store.dispatch(actions.skipBreak({ pausedTaskId }));
|
||||
});
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {
|
||||
label: T.F.FOCUS_MODE.B.END_SESSION,
|
||||
fn: () => {
|
||||
this.store.dispatch(actions.completeFocusSession({ isManual: true }));
|
||||
},
|
||||
};
|
||||
|
||||
// Open overlay button
|
||||
const overlayAction = {
|
||||
label: T.F.FOCUS_MODE.B.TO_FOCUS_OVERLAY,
|
||||
fn: () => {
|
||||
this.store.dispatch(showFocusOverlay());
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
action: playPauseAction,
|
||||
action2: endAction,
|
||||
action3: overlayAction,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -855,7 +1001,10 @@ export class FocusModeEffects {
|
|||
focusModeConfig?.isManualBreakStart &&
|
||||
strategy.shouldStartBreakAfterSession
|
||||
) {
|
||||
const breakInfo = strategy.getBreakDuration(cycle ?? 1);
|
||||
// Bug #5737 fix: Use cycle - 1 since incrementCycle fires before user clicks
|
||||
// This ensures long break occurs after session 4, not session 5
|
||||
const actualCycle = Math.max(1, (cycle || 1) - 1);
|
||||
const breakInfo = strategy.getBreakDuration(actualCycle);
|
||||
if (breakInfo) {
|
||||
const currentTaskId = this.taskService.currentTaskId();
|
||||
const shouldPauseTracking =
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
<ng-container>
|
||||
@if (task()?.issueWasUpdated) {
|
||||
<div
|
||||
@expand
|
||||
style="text-align: center"
|
||||
>
|
||||
<button
|
||||
(click)="hideUpdates()"
|
||||
color="accent"
|
||||
mat-raised-button
|
||||
>
|
||||
{{ T.F.CALDAV.ISSUE_CONTENT.MARK_AS_CHECKED | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="issue-table">
|
||||
<tr>
|
||||
<th>{{ T.F.CALDAV.ISSUE_CONTENT.SUMMARY | translate }}</th>
|
||||
<td>
|
||||
<strong>{{ issue?.summary }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ T.F.CALDAV.ISSUE_CONTENT.STATUS | translate }}</th>
|
||||
<td>{{ issue?.completed }}</td>
|
||||
</tr>
|
||||
@if (issue?.labels?.length) {
|
||||
<tr>
|
||||
<th>{{ T.F.CALDAV.ISSUE_CONTENT.LABELS | translate }}</th>
|
||||
<td>
|
||||
<mat-chip-listbox>
|
||||
@for (label of issue?.labels; track label) {
|
||||
<mat-chip-option [title]="label">{{ label }} </mat-chip-option>
|
||||
}
|
||||
</mat-chip-listbox>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?.note) {
|
||||
<tr>
|
||||
<th>{{ T.F.CALDAV.ISSUE_CONTENT.DESCRIPTION | translate }}</th>
|
||||
<td class="issue-description">
|
||||
<div
|
||||
[data]="issue?.note"
|
||||
class="description markdown"
|
||||
markdown
|
||||
></div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
// table styled by ./src/styles/components/issue-table.scss
|
||||
|
||||
.table-wrapper {
|
||||
margin: var(--s2);
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
|
||||
.issue-description {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comment {
|
||||
display: flex;
|
||||
margin-bottom: var(--s);
|
||||
padding-top: var(--s);
|
||||
border-top: 1px dashed var(--extra-border-color);
|
||||
|
||||
border-color: var(--extra-border-color);
|
||||
|
||||
.author-avatar {
|
||||
margin-right: var(--s);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.when {
|
||||
margin-left: var(--s);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.name-and-comment-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
border: 1px dashed var(--extra-border-color);
|
||||
padding: var(--s);
|
||||
}
|
||||
}
|
||||
|
||||
.write-a-comment {
|
||||
// needed for the box-shadow inside scrollable container
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { ChangeDetectionStrategy, Component, Input, input, inject } from '@angular/core';
|
||||
import { TaskWithSubTasks } from '../../../../tasks/task.model';
|
||||
import { expandAnimation } from '../../../../../ui/animations/expand.ani';
|
||||
import { T } from '../../../../../t.const';
|
||||
import { TaskService } from '../../../../tasks/task.service';
|
||||
import { CaldavIssue } from '../caldav-issue.model';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatChipListbox, MatChipOption } from '@angular/material/chips';
|
||||
import { MarkdownComponent } from 'ngx-markdown';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'caldav-issue-content',
|
||||
templateUrl: './caldav-issue-content.component.html',
|
||||
styleUrls: ['./caldav-issue-content.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandAnimation],
|
||||
imports: [MatButton, MatChipListbox, MatChipOption, MarkdownComponent, TranslatePipe],
|
||||
})
|
||||
export class CaldavIssueContentComponent {
|
||||
private readonly _taskService = inject(TaskService);
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// This input is used in a control flow expression (e.g. `@if` or `*ngIf`)
|
||||
// and migrating would break narrowing currently.
|
||||
@Input() issue?: CaldavIssue;
|
||||
readonly task = input<TaskWithSubTasks>();
|
||||
|
||||
T: typeof T = T;
|
||||
|
||||
hideUpdates(): void {
|
||||
const task = this.task();
|
||||
if (!task) {
|
||||
throw new Error('No task');
|
||||
}
|
||||
if (!this.issue) {
|
||||
throw new Error('No issue');
|
||||
}
|
||||
this._taskService.markIssueUpdatesAsRead(task.id);
|
||||
}
|
||||
|
||||
trackByIndex(i: number, p: any): number {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
<ng-container>
|
||||
@if (task()?.issueWasUpdated) {
|
||||
<div
|
||||
@expand
|
||||
style="text-align: center"
|
||||
>
|
||||
<button
|
||||
(click)="hideUpdates()"
|
||||
color="accent"
|
||||
mat-raised-button
|
||||
>
|
||||
{{ T.F.GITEA.ISSUE_CONTENT.MARK_AS_CHECKED | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="issue-table">
|
||||
<tr>
|
||||
<th>{{ T.F.GITEA.ISSUE_CONTENT.SUMMARY | translate }}</th>
|
||||
<td>
|
||||
<a
|
||||
[href]="issue?.html_url"
|
||||
target="_blank"
|
||||
><strong>{{ issue?.title }} #{{ issue?.number }}</strong></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ T.F.GITEA.ISSUE_CONTENT.PROJECT | translate }}</th>
|
||||
<td>{{ issue?.repository.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ T.F.GITEA.ISSUE_CONTENT.STATUS | translate }}</th>
|
||||
<td>{{ issue?.state }}</td>
|
||||
</tr>
|
||||
@if (issue?.assignee?.web_url) {
|
||||
<tr>
|
||||
<th>{{ T.F.GITEA.ISSUE_CONTENT.ASSIGNEE | translate }}</th>
|
||||
<td>
|
||||
<a
|
||||
[href]="issue?.assignee?.web_url"
|
||||
target="_blank"
|
||||
>{{ issue?.assignee?.username }}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?.labels?.length) {
|
||||
<tr>
|
||||
<th>{{ T.F.GITEA.ISSUE_CONTENT.LABELS | translate }}</th>
|
||||
<td>
|
||||
<mat-chip-listbox>
|
||||
@for (label of issue?.labels; track label) {
|
||||
<mat-chip-option [title]="label.name">{{ label.name }} </mat-chip-option>
|
||||
}
|
||||
</mat-chip-listbox>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?.body) {
|
||||
<tr>
|
||||
<th>{{ T.F.GITEA.ISSUE_CONTENT.DESCRIPTION | translate }}</th>
|
||||
<td class="issue-description">
|
||||
<div
|
||||
[data]="issue?.body"
|
||||
class="description markdown"
|
||||
markdown
|
||||
></div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
<div style="text-align: center">
|
||||
<a
|
||||
[href]="issue?.html_url"
|
||||
class="write-a-comment"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
target="_blank"
|
||||
>
|
||||
<mat-icon>textsms</mat-icon>
|
||||
{{ T.F.GITEA.ISSUE_CONTENT.WRITE_A_COMMENT | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { Component, ChangeDetectionStrategy, Input, input, inject } from '@angular/core';
|
||||
import { TaskWithSubTasks } from 'src/app/features/tasks/task.model';
|
||||
import { TaskService } from 'src/app/features/tasks/task.service';
|
||||
import { T } from 'src/app/t.const';
|
||||
import { expandAnimation } from 'src/app/ui/animations/expand.ani';
|
||||
import { GiteaIssue } from '../gitea-issue.model';
|
||||
import { MatButton, MatAnchor } from '@angular/material/button';
|
||||
import { MatChipListbox, MatChipOption } from '@angular/material/chips';
|
||||
import { MarkdownComponent } from 'ngx-markdown';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'gitea-issue-content',
|
||||
templateUrl: './gitea-issue-content.component.html',
|
||||
styleUrls: ['./gitea-issue-content.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandAnimation],
|
||||
imports: [
|
||||
MatButton,
|
||||
MatChipListbox,
|
||||
MatChipOption,
|
||||
MarkdownComponent,
|
||||
MatAnchor,
|
||||
MatIcon,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class GiteaIssueContentComponent {
|
||||
private readonly _taskService = inject(TaskService);
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// This input is used in a control flow expression (e.g. `@if` or `*ngIf`)
|
||||
// and migrating would break narrowing currently.
|
||||
@Input() issue?: GiteaIssue;
|
||||
readonly task = input<TaskWithSubTasks>();
|
||||
|
||||
T: typeof T = T;
|
||||
|
||||
hideUpdates(): void {
|
||||
const task = this.task();
|
||||
if (!task) {
|
||||
throw new Error('No task');
|
||||
}
|
||||
if (!this.issue) {
|
||||
throw new Error('No issue');
|
||||
}
|
||||
this._taskService.markIssueUpdatesAsRead(task.id);
|
||||
}
|
||||
|
||||
trackByIndex(i: number, p: any): number {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
<ng-container>
|
||||
@if (task()?.issueWasUpdated) {
|
||||
<div
|
||||
@expand
|
||||
style="text-align: center"
|
||||
>
|
||||
<button
|
||||
(click)="hideUpdates()"
|
||||
color="accent"
|
||||
mat-raised-button
|
||||
>
|
||||
{{ T.F.GITHUB.ISSUE_CONTENT.MARK_AS_CHECKED | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="issue-table">
|
||||
<tr>
|
||||
<th>{{ T.F.GITHUB.ISSUE_CONTENT.SUMMARY | translate }}</th>
|
||||
<td>
|
||||
<a
|
||||
[href]="issue?.html_url"
|
||||
target="_blank"
|
||||
><strong>{{ issue?.title }} #{{ issue?.number }}</strong></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ T.F.GITHUB.ISSUE_CONTENT.STATUS | translate }}</th>
|
||||
<td>{{ issue?.state }}</td>
|
||||
</tr>
|
||||
@if (issue?.assignee?.html_url) {
|
||||
<tr>
|
||||
<th>{{ T.F.GITHUB.ISSUE_CONTENT.ASSIGNEE | translate }}</th>
|
||||
<td>
|
||||
<a
|
||||
[href]="issue?.assignee?.html_url"
|
||||
target="_blank"
|
||||
>{{ issue?.assignee?.login }}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?.labels?.length) {
|
||||
<tr>
|
||||
<th>{{ T.F.GITHUB.ISSUE_CONTENT.LABELS | translate }}</th>
|
||||
<td>
|
||||
<mat-chip-listbox>
|
||||
@for (label of issue?.labels; track trackByIndex($index, label)) {
|
||||
<mat-chip-option [title]="label.description"
|
||||
>{{ label.name }}
|
||||
</mat-chip-option>
|
||||
}
|
||||
</mat-chip-listbox>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?.body && !isCollapsedIssueSummary()) {
|
||||
<tr>
|
||||
<th>{{ T.F.GITHUB.ISSUE_CONTENT.DESCRIPTION | translate }}</th>
|
||||
<td class="issue-description">
|
||||
<div
|
||||
[data]="issue?.body"
|
||||
class="description markdown"
|
||||
markdown
|
||||
></div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
|
||||
@if (issue?.comments?.length) {
|
||||
<div>
|
||||
@if (isCollapsedIssueComments()) {
|
||||
<div style="text-align: center">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="load-comments-and-all-data"
|
||||
(click)="isForceShowAllComments.set(true)"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
@if (isCollapsedIssueSummary()) {
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap"
|
||||
>{{
|
||||
T.F.GITHUB.ISSUE_CONTENT.LOAD_DESCRIPTION_AND_ALL_COMMENTS | translate
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
@if (!isCollapsedIssueSummary()) {
|
||||
{{
|
||||
T.F.GITHUB.ISSUE_CONTENT.LOAD_ALL_COMMENTS
|
||||
| translate: { nr: issue?.comments?.length }
|
||||
}}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<h3 class="last-comment-headline">
|
||||
{{ T.F.GITHUB.ISSUE_CONTENT.LAST_COMMENT | translate }}
|
||||
</h3>
|
||||
<div class="comment isLastComment">
|
||||
<!--<img [src]="comment.author.avatarUrl"-->
|
||||
<!--class="author-avatar">-->
|
||||
<div class="name-and-comment-content">
|
||||
<div>
|
||||
<span class="author-name">{{ lastComment().user?.login }}</span>
|
||||
<span class="when"
|
||||
>{{ T.F.GITHUB.ISSUE_CONTENT.AT | translate }}
|
||||
{{ lastComment().created_at | localeDate: 'short' }}</span
|
||||
>
|
||||
</div>
|
||||
@if (lastComment().body) {
|
||||
<div
|
||||
[innerHTML]="lastComment().body | markdown | async"
|
||||
class="markdown"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!isCollapsedIssueComments()) {
|
||||
@for (comment of sortedComments; track trackByIndex($index, comment)) {
|
||||
<div class="comment">
|
||||
<!--<img [src]="comment.author.avatarUrl"-->
|
||||
<!--class="author-avatar">-->
|
||||
<div class="name-and-comment-content">
|
||||
<div>
|
||||
<span class="author-name">{{ comment.user?.login }}</span>
|
||||
<span class="when"
|
||||
>{{ T.F.GITHUB.ISSUE_CONTENT.AT | translate }}
|
||||
{{ comment.created_at | localeDate: 'short' }}</span
|
||||
>
|
||||
</div>
|
||||
@if (comment.body) {
|
||||
<div
|
||||
[innerHTML]="comment?.body | markdown | async"
|
||||
class="markdown"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<!-- <div-->
|
||||
<!-- *ngFor="let comment of (issue?.comments|sort:'created_at'); trackBy: trackByIndex"-->
|
||||
<!-- class="comment"-->
|
||||
<!-- >-->
|
||||
<!-- <!–<img [src]="comment.author.avatarUrl"–>-->
|
||||
<!-- <!–class="author-avatar">–>-->
|
||||
<!-- <div class="name-and-comment-content">-->
|
||||
<!-- <div>-->
|
||||
<!-- <span class="author-name">{{comment.user?.login}}</span>-->
|
||||
<!-- <span class="when"-->
|
||||
<!-- >{{T.F.GITHUB.ISSUE_CONTENT.AT|translate}}-->
|
||||
<!-- {{comment.created_at|date:'short'}}</span-->
|
||||
<!-- >-->
|
||||
<!-- </div>-->
|
||||
<!-- <div-->
|
||||
<!-- *ngIf="comment.body"-->
|
||||
<!-- [innerHTML]="comment?.body|markdown"-->
|
||||
<!-- class="markdown"-->
|
||||
<!-- ></div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
}
|
||||
|
||||
<div style="text-align: center">
|
||||
<a
|
||||
[href]="issue?.html_url"
|
||||
class="write-a-comment"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
target="_blank"
|
||||
>
|
||||
<mat-icon>textsms</mat-icon>
|
||||
{{ T.F.GITHUB.ISSUE_CONTENT.WRITE_A_COMMENT | translate }}
|
||||
</a>
|
||||
</div>
|
||||
<!--<pre><code>-->
|
||||
<!--{{issue?|json}}-->
|
||||
<!--</code></pre>-->
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
@use '../../../../../../styles/_globals.scss' as *;
|
||||
|
||||
// table styled by ./src/styles/components/issue-table.scss
|
||||
|
||||
.table-wrapper {
|
||||
margin: var(--s2);
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
|
||||
.issue-description {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.last-comment-headline {
|
||||
margin-top: var(--s2);
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comment {
|
||||
display: flex;
|
||||
margin-bottom: var(--s);
|
||||
padding-top: var(--s);
|
||||
border-top: 1px dashed var(--extra-border-color);
|
||||
|
||||
border-color: var(--extra-border-color);
|
||||
|
||||
&.isLastComment {
|
||||
border-top: 0 !important;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
margin-right: var(--s);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.when {
|
||||
margin-left: var(--s);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.name-and-comment-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
border: 1px dashed var(--extra-border-color);
|
||||
padding: var(--s);
|
||||
}
|
||||
}
|
||||
|
||||
.write-a-comment {
|
||||
// needed for the box-shadow inside scrollable container
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
|
||||
.load-comments-and-all-data {
|
||||
max-width: 100%;
|
||||
|
||||
::ng-deep .mdc-button__label {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
::ng-deep .mdc-button__label > span {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
//
|
||||
// import { GithubIssueContentComponent } from './github-issue-content.component';
|
||||
//
|
||||
// describe('GithubIssueContentComponent', () => {
|
||||
// let component: GithubIssueContentComponent;
|
||||
// let fixture: ComponentFixture<GithubIssueContentComponent>;
|
||||
//
|
||||
// beforeEach(async(() => {
|
||||
// TestBed.configureTestingModule({
|
||||
// declarations: [GithubIssueContentComponent]
|
||||
// })
|
||||
// .compileComponents();
|
||||
// }));
|
||||
//
|
||||
// beforeEach(() => {
|
||||
// fixture = TestBed.createComponent(GithubIssueContentComponent);
|
||||
// component = fixture.componentInstance;
|
||||
// fixture.detectChanges();
|
||||
// });
|
||||
//
|
||||
// it('should create', () => {
|
||||
// expect(component).toBeTruthy();
|
||||
// });
|
||||
// });
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
input,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { TaskWithSubTasks } from '../../../../tasks/task.model';
|
||||
import { GithubComment, GithubIssue } from '../github-issue.model';
|
||||
import { expandAnimation } from '../../../../../ui/animations/expand.ani';
|
||||
import { T } from '../../../../../t.const';
|
||||
import { TaskService } from '../../../../tasks/task.service';
|
||||
import { MatButton, MatAnchor } from '@angular/material/button';
|
||||
import { MatChipListbox, MatChipOption } from '@angular/material/chips';
|
||||
import { MarkdownComponent, MarkdownPipe } from 'ngx-markdown';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'github-issue-content',
|
||||
templateUrl: './github-issue-content.component.html',
|
||||
styleUrls: ['./github-issue-content.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandAnimation],
|
||||
imports: [
|
||||
MatButton,
|
||||
MatChipListbox,
|
||||
MatChipOption,
|
||||
MarkdownComponent,
|
||||
MatIcon,
|
||||
MatAnchor,
|
||||
AsyncPipe,
|
||||
LocaleDatePipe,
|
||||
TranslatePipe,
|
||||
MarkdownPipe,
|
||||
],
|
||||
})
|
||||
export class GithubIssueContentComponent {
|
||||
private readonly _taskService = inject(TaskService);
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// This input is used in a control flow expression (e.g. `@if` or `*ngIf`)
|
||||
// and migrating would break narrowing currently.
|
||||
@Input() issue?: GithubIssue;
|
||||
readonly task = input<TaskWithSubTasks>();
|
||||
|
||||
T: typeof T = T;
|
||||
|
||||
isForceShowAllComments = signal(false);
|
||||
|
||||
lastComment(): GithubComment {
|
||||
// NOTE: when we ask for this we should have it
|
||||
return (this.issue?.comments &&
|
||||
this.issue.comments[this.issue.comments?.length - 1]) as GithubComment;
|
||||
}
|
||||
|
||||
get sortedComments(): readonly GithubComment[] {
|
||||
if (!this.issue?.comments) {
|
||||
return [];
|
||||
}
|
||||
return [...this.issue.comments].sort((a, b) =>
|
||||
a.created_at.localeCompare(b.created_at),
|
||||
);
|
||||
}
|
||||
|
||||
isCollapsedIssueSummary(): boolean {
|
||||
if (this.issue) {
|
||||
return this.isCollapsedIssueComments() && this.issue.body?.length > 200;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isCollapsedIssueComments(): boolean {
|
||||
if (this.issue) {
|
||||
return !this.isForceShowAllComments() && this.issue.comments?.length > 2;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
hideUpdates(): void {
|
||||
const task = this.task();
|
||||
if (!task) {
|
||||
throw new Error('No task');
|
||||
}
|
||||
if (!this.issue) {
|
||||
throw new Error('No issue');
|
||||
}
|
||||
this._taskService.markIssueUpdatesAsRead(task.id);
|
||||
}
|
||||
|
||||
trackByIndex(i: number, p: GithubComment): number {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
<ng-container>
|
||||
@if (task()?.issueWasUpdated) {
|
||||
<div
|
||||
@expand
|
||||
style="text-align: center"
|
||||
>
|
||||
<button
|
||||
(click)="hideUpdates()"
|
||||
color="accent"
|
||||
mat-raised-button
|
||||
>
|
||||
{{ T.F.GITLAB.ISSUE_CONTENT.MARK_AS_CHECKED | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="issue-table">
|
||||
<tr>
|
||||
<th>{{ T.F.GITLAB.ISSUE_CONTENT.SUMMARY | translate }}</th>
|
||||
<td>
|
||||
<a
|
||||
[href]="issue?.html_url"
|
||||
target="_blank"
|
||||
><strong>{{ issue?.title }} #{{ issue?.number }}</strong></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- <tr>-->
|
||||
<!-- <th>{{T.F.GITLAB.ISSUE_CONTENT.PROJECT|translate}}</th>-->
|
||||
<!-- <td>{{issue?.project}}</td>-->
|
||||
<!-- </tr>-->
|
||||
<tr>
|
||||
<th>{{ T.F.GITLAB.ISSUE_CONTENT.STATUS | translate }}</th>
|
||||
<td>{{ issue?.state }}</td>
|
||||
</tr>
|
||||
@if (issue?.assignee?.web_url) {
|
||||
<tr>
|
||||
<th>{{ T.F.GITLAB.ISSUE_CONTENT.ASSIGNEE | translate }}</th>
|
||||
<td>
|
||||
<a
|
||||
[href]="issue?.assignee?.web_url"
|
||||
target="_blank"
|
||||
>{{ issue?.assignee?.username }}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?.labels?.length) {
|
||||
<tr>
|
||||
<th>{{ T.F.GITLAB.ISSUE_CONTENT.LABELS | translate }}</th>
|
||||
<td>
|
||||
<mat-chip-listbox>
|
||||
@for (label of issue?.labels; track label) {
|
||||
<mat-chip-option [title]="label">{{ label }} </mat-chip-option>
|
||||
}
|
||||
</mat-chip-listbox>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?.body) {
|
||||
<tr>
|
||||
<th>{{ T.F.GITLAB.ISSUE_CONTENT.DESCRIPTION | translate }}</th>
|
||||
<td class="issue-description">
|
||||
<div
|
||||
[data]="issue?.body"
|
||||
class="description markdown"
|
||||
markdown
|
||||
></div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
|
||||
@if (issue?.comments) {
|
||||
<div>
|
||||
@for (comment of sortedComments; track trackByIndex($index, comment)) {
|
||||
<div class="comment">
|
||||
<div class="name-and-comment-content">
|
||||
<div>
|
||||
<span class="author-name">{{ comment.author?.username }}</span>
|
||||
<span class="when"
|
||||
>{{ T.F.GITLAB.ISSUE_CONTENT.AT | translate }}
|
||||
{{ comment.created_at | localeDate: 'short' }}</span
|
||||
>
|
||||
</div>
|
||||
@if (comment.body) {
|
||||
<div
|
||||
[innerHTML]="comment?.body | markdown | async"
|
||||
class="markdown"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div style="text-align: center">
|
||||
<a
|
||||
[href]="issue?.url"
|
||||
class="write-a-comment"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
target="_blank"
|
||||
>
|
||||
<mat-icon>textsms</mat-icon>
|
||||
{{ T.F.GITLAB.ISSUE_CONTENT.WRITE_A_COMMENT | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
@use '../../../../../../styles/_globals.scss' as *;
|
||||
|
||||
// table styled by ./src/styles/components/issue-table.scss
|
||||
|
||||
.table-wrapper {
|
||||
margin: var(--s2);
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
|
||||
.issue-description {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comment {
|
||||
display: flex;
|
||||
margin-bottom: var(--s);
|
||||
padding-top: var(--s);
|
||||
border-top: 1px dashed var(--extra-border-color);
|
||||
|
||||
border-color: var(--extra-border-color);
|
||||
|
||||
.author-avatar {
|
||||
margin-right: var(--s);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.when {
|
||||
margin-left: var(--s);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.name-and-comment-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
border: 1px dashed var(--extra-border-color);
|
||||
padding: var(--s);
|
||||
}
|
||||
}
|
||||
|
||||
.write-a-comment {
|
||||
// needed for the box-shadow inside scrollable container
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { ChangeDetectionStrategy, Component, Input, input, inject } from '@angular/core';
|
||||
import { TaskWithSubTasks } from '../../../../tasks/task.model';
|
||||
import { GitlabComment, GitlabIssue } from '../gitlab-issue.model';
|
||||
import { expandAnimation } from '../../../../../ui/animations/expand.ani';
|
||||
import { T } from '../../../../../t.const';
|
||||
import { TaskService } from '../../../../tasks/task.service';
|
||||
import { MatButton, MatAnchor } from '@angular/material/button';
|
||||
import { MatChipListbox, MatChipOption } from '@angular/material/chips';
|
||||
import { MarkdownComponent, MarkdownPipe } from 'ngx-markdown';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'gitlab-issue-content',
|
||||
templateUrl: './gitlab-issue-content.component.html',
|
||||
styleUrls: ['./gitlab-issue-content.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandAnimation],
|
||||
imports: [
|
||||
MatButton,
|
||||
MatChipListbox,
|
||||
MatChipOption,
|
||||
MarkdownComponent,
|
||||
MatAnchor,
|
||||
MatIcon,
|
||||
AsyncPipe,
|
||||
LocaleDatePipe,
|
||||
TranslatePipe,
|
||||
MarkdownPipe,
|
||||
],
|
||||
})
|
||||
export class GitlabIssueContentComponent {
|
||||
private readonly _taskService = inject(TaskService);
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// This input is used in a control flow expression (e.g. `@if` or `*ngIf`)
|
||||
// and migrating would break narrowing currently.
|
||||
@Input() issue?: GitlabIssue;
|
||||
readonly task = input<TaskWithSubTasks>();
|
||||
|
||||
T: typeof T = T;
|
||||
|
||||
hideUpdates(): void {
|
||||
const task = this.task();
|
||||
if (!task) {
|
||||
throw new Error('No task');
|
||||
}
|
||||
if (!this.issue) {
|
||||
throw new Error('No issue');
|
||||
}
|
||||
this._taskService.markIssueUpdatesAsRead(task.id);
|
||||
}
|
||||
|
||||
trackByIndex(i: number, p: GitlabComment): number {
|
||||
return i;
|
||||
}
|
||||
|
||||
get sortedComments(): GitlabComment[] {
|
||||
if (!this.issue?.comments) {
|
||||
return [];
|
||||
}
|
||||
return [...this.issue.comments].sort((a, b) =>
|
||||
a.created_at.localeCompare(b.created_at),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
<div class="wrapper">
|
||||
@if (task?.issueWasUpdated) {
|
||||
<div
|
||||
[@expand]
|
||||
class="updates"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
(click)="hideUpdates()"
|
||||
color="accent"
|
||||
mat-raised-button
|
||||
>
|
||||
{{ T.F.JIRA.ISSUE_CONTENT.MARK_AS_CHECKED | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<h3 class="mat-h3">{{ T.F.JIRA.ISSUE_CONTENT.LIST_OF_CHANGES | translate }}</h3>
|
||||
<ul class="changelog">
|
||||
@for (entry of issue?.changelog; track entry) {
|
||||
<li>
|
||||
@if (entry.author) {
|
||||
<em>{{ entry.author.displayName }}</em>
|
||||
}
|
||||
{{ T.F.JIRA.ISSUE_CONTENT.CHANGED | translate }}
|
||||
<strong>{{ entry.field }}</strong>
|
||||
{{ T.F.JIRA.ISSUE_CONTENT.ON | translate }}
|
||||
{{ entry.created | localeDate: 'short' }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="issue-table">
|
||||
<tr>
|
||||
<th>{{ T.F.JIRA.ISSUE_CONTENT.SUMMARY | translate }}</th>
|
||||
<td class="summary">
|
||||
<strong
|
||||
><a
|
||||
[href]="issueUrl$ | async"
|
||||
target="_blank"
|
||||
>{{ issue?.key }} {{ issue?.summary }}</a
|
||||
></strong
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ T.F.JIRA.ISSUE_CONTENT.STATUS | translate }}</th>
|
||||
<td>
|
||||
<!--<img [src]="issue?.status?.iconUrl"-->
|
||||
<!--*ngIf="issue?.status?.iconUrl">-->
|
||||
{{ issue?.status?.name }}
|
||||
</td>
|
||||
</tr>
|
||||
@if (issue?.storyPoints) {
|
||||
<tr>
|
||||
<th>{{ T.F.JIRA.ISSUE_CONTENT.STORY_POINTS | translate }}</th>
|
||||
<td>{{ issue?.storyPoints }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<th>{{ T.F.JIRA.ISSUE_CONTENT.ASSIGNEE | translate }}</th>
|
||||
<td>{{ issue?.assignee?.displayName || '–' }}</td>
|
||||
</tr>
|
||||
@if (issue?.timespent || issue?.timeestimate) {
|
||||
<tr>
|
||||
<th>{{ T.F.JIRA.ISSUE_CONTENT.WORKLOG | translate }}</th>
|
||||
<td>
|
||||
{{ issue?.timespent * 1000 | msToString }} /
|
||||
{{ issue?.timeestimate * 1000 | msToString }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (jiraSubTasks$ | async; as jiraSubTasks) {
|
||||
<tr>
|
||||
<th>{{ T.F.JIRA.ISSUE_CONTENT.SUB_TASKS | translate }}</th>
|
||||
<td>
|
||||
<ul class="subtask-list">
|
||||
@for (st of jiraSubTasks; track st) {
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
[href]="st.href"
|
||||
>
|
||||
{{ st.key }} {{ st.summary }}</a
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (jiraRelatedIssues$ | async; as relatedIssues) {
|
||||
<tr>
|
||||
<th>{{ T.F.JIRA.ISSUE_CONTENT.RELATED | translate }}</th>
|
||||
<td>
|
||||
<ul class="related-issue-list">
|
||||
@for (ri of relatedIssues; track ri.id) {
|
||||
<li>
|
||||
<i>{{ ri.relatedHow }} => </i>
|
||||
<a
|
||||
target="_blank"
|
||||
[href]="ri['href']"
|
||||
>{{ ri.key }} {{ ri.summary }}</a
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?.components?.length) {
|
||||
<tr>
|
||||
<th>{{ T.F.JIRA.ISSUE_CONTENT.COMPONENTS | translate }}</th>
|
||||
<td>
|
||||
<mat-chip-listbox>
|
||||
@for (component of issue?.components; track trackByIndex($index, component)) {
|
||||
<mat-chip-option [title]="component.description"
|
||||
>{{ component.name }}
|
||||
</mat-chip-option>
|
||||
}
|
||||
</mat-chip-listbox>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?.description) {
|
||||
<tr class="description-row">
|
||||
<th>{{ T.F.JIRA.ISSUE_CONTENT.DESCRIPTION | translate }}</th>
|
||||
<td>
|
||||
@if (description) {
|
||||
<div
|
||||
[data]="description"
|
||||
class="issue-description markdown"
|
||||
markdown
|
||||
></div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
<!-- <tr *ngIf="attachments?.length">-->
|
||||
<!-- <th>{{T.F.JIRA.ISSUE_CONTENT.ATTACHMENTS|translate}}</th>-->
|
||||
<!-- <td>-->
|
||||
<!-- <div [class.hasAttachments]="attachments?.length"-->
|
||||
<!-- class="attachments">-->
|
||||
<!-- <attachment-list [attachments]="attachments"-->
|
||||
<!-- [isDisableControls]="true"></attachment-list>-->
|
||||
<!-- </div>-->
|
||||
<!-- </td>-->
|
||||
<!-- </tr>-->
|
||||
@if (issue?.comments) {
|
||||
<tr>
|
||||
<th>{{ T.F.JIRA.ISSUE_CONTENT.COMMENTS | translate }}</th>
|
||||
<td>
|
||||
@for (comment of sortedComments; track trackByIndex($index, comment)) {
|
||||
<div class="comment">
|
||||
<img
|
||||
[src]="comment.author.avatarUrl"
|
||||
class="author-avatar"
|
||||
/>
|
||||
<div class="name-and-comment-content">
|
||||
<div>
|
||||
<span class="author-name">{{ comment.author.displayName }}</span>
|
||||
<span class="when"
|
||||
>{{ T.F.JIRA.ISSUE_CONTENT.AT | translate }}
|
||||
{{ comment.created | localeDate: 'short' }}</span
|
||||
>
|
||||
</div>
|
||||
@if (comment.body) {
|
||||
<div
|
||||
[innerHTML]="comment.body | jiraToMarkdown | markdown | async"
|
||||
class="markdown"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<a
|
||||
[href]="issueUrl$ | async"
|
||||
mat-stroked-button
|
||||
target="_blank"
|
||||
>
|
||||
<mat-icon>textsms</mat-icon>
|
||||
{{ T.F.JIRA.ISSUE_CONTENT.WRITE_A_COMMENT | translate }}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
@use '../../../../../../styles/_globals.scss' as *;
|
||||
|
||||
// table styled by ./src/styles/components/issue-table.scss
|
||||
|
||||
.issue-description {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.issue-table {
|
||||
.description-row {
|
||||
th {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin: var(--s2);
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
|
||||
.comment {
|
||||
display: flex;
|
||||
margin-bottom: var(--s2);
|
||||
align-items: flex-start;
|
||||
|
||||
.author-avatar {
|
||||
margin-right: var(--s);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.when {
|
||||
margin-left: var(--s);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
//margin-bottom: var(--s);
|
||||
// because they have an inner padding
|
||||
&.hasAttachments {
|
||||
margin-top: calc(-1 * var(--s));
|
||||
}
|
||||
}
|
||||
|
||||
.updates {
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
margin-top: var(--s);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.changelog {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--s2);
|
||||
padding: 0 0 var(--s2);
|
||||
max-height: 100px;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.summary a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.subtask-list,
|
||||
.related-issue-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.subtask-list {
|
||||
margin-left: var(--s2);
|
||||
}
|
||||
|
||||
.related-issue-list {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
//
|
||||
// import { JiraIssueContentComponent } from './jira-issue-content.component';
|
||||
//
|
||||
// describe('JiraIssueContentComponent', () => {
|
||||
// let component: JiraIssueContentComponent;
|
||||
// let fixture: ComponentFixture<JiraIssueContentComponent>;
|
||||
//
|
||||
// beforeEach(async(() => {
|
||||
// TestBed.configureTestingModule({
|
||||
// declarations: [JiraIssueContentComponent]
|
||||
// })
|
||||
// .compileComponents();
|
||||
// }));
|
||||
//
|
||||
// beforeEach(() => {
|
||||
// fixture = TestBed.createComponent(JiraIssueContentComponent);
|
||||
// component = fixture.componentInstance;
|
||||
// fixture.detectChanges();
|
||||
// });
|
||||
//
|
||||
// it('should create', () => {
|
||||
// expect(component).toBeTruthy();
|
||||
// });
|
||||
// });
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
import { ChangeDetectionStrategy, Component, Input, inject } from '@angular/core';
|
||||
import { TaskWithSubTasks } from '../../../../tasks/task.model';
|
||||
import {
|
||||
JiraComment,
|
||||
JiraIssue,
|
||||
JiraRelatedIssue,
|
||||
JiraSubtask,
|
||||
} from '../jira-issue.model';
|
||||
import { expandAnimation } from '../../../../../ui/animations/expand.ani';
|
||||
import { TaskAttachment } from '../../../../tasks/task-attachment/task-attachment.model';
|
||||
import { T } from '../../../../../t.const';
|
||||
import { TaskService } from '../../../../tasks/task.service';
|
||||
// @ts-ignore
|
||||
import j2m from 'jira2md';
|
||||
import {
|
||||
combineLatest,
|
||||
forkJoin,
|
||||
from,
|
||||
Observable,
|
||||
of,
|
||||
ReplaySubject,
|
||||
Subject,
|
||||
} from 'rxjs';
|
||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||
import { JiraCommonInterfacesService } from '../jira-common-interfaces.service';
|
||||
import { devError } from '../../../../../util/dev-error';
|
||||
import { assertTruthy } from '../../../../../util/assert-truthy';
|
||||
import { MatButton, MatAnchor } from '@angular/material/button';
|
||||
import { MatChipListbox, MatChipOption } from '@angular/material/chips';
|
||||
import { MarkdownComponent, MarkdownPipe } from 'ngx-markdown';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
|
||||
import { JiraToMarkdownPipe } from '../../../../../ui/pipes/jira-to-markdown.pipe';
|
||||
import { MsToStringPipe } from '../../../../../ui/duration/ms-to-string.pipe';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SnackService } from '../../../../../core/snack/snack.service';
|
||||
import { IssueLog } from '../../../../../core/log';
|
||||
|
||||
interface JiraSubtaskWithUrl extends JiraSubtask {
|
||||
href: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'jira-issue-content',
|
||||
templateUrl: './jira-issue-content.component.html',
|
||||
styleUrls: ['./jira-issue-content.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandAnimation],
|
||||
imports: [
|
||||
MatButton,
|
||||
MatChipListbox,
|
||||
MatChipOption,
|
||||
MarkdownComponent,
|
||||
MatAnchor,
|
||||
MatIcon,
|
||||
AsyncPipe,
|
||||
LocaleDatePipe,
|
||||
JiraToMarkdownPipe,
|
||||
MsToStringPipe,
|
||||
TranslatePipe,
|
||||
MarkdownPipe,
|
||||
],
|
||||
})
|
||||
export class JiraIssueContentComponent {
|
||||
private readonly _taskService = inject(TaskService);
|
||||
private readonly _snackService = inject(SnackService);
|
||||
private readonly _jiraCommonInterfacesService = inject(JiraCommonInterfacesService);
|
||||
|
||||
description?: string;
|
||||
attachments?: TaskAttachment[];
|
||||
T: typeof T = T;
|
||||
issue?: JiraIssue;
|
||||
task?: TaskWithSubTasks;
|
||||
private _task$: Subject<TaskWithSubTasks> = new ReplaySubject(1);
|
||||
private _issue$: Subject<JiraIssue> = new ReplaySubject(1);
|
||||
|
||||
issueUrl$: Observable<string> = this._task$.pipe(
|
||||
switchMap((task) =>
|
||||
from(
|
||||
this._jiraCommonInterfacesService.issueLink(
|
||||
assertTruthy(task.issueId),
|
||||
assertTruthy(task.issueProviderId),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
jiraSubTasks$: Observable<JiraSubtaskWithUrl[] | undefined> = combineLatest([
|
||||
this._task$,
|
||||
this._issue$,
|
||||
]).pipe(
|
||||
switchMap(([task, issue]) =>
|
||||
issue.subtasks?.length
|
||||
? forkJoin(
|
||||
...issue.subtasks.map((ist: JiraSubtask) => {
|
||||
return from(
|
||||
this._jiraCommonInterfacesService.issueLink(
|
||||
assertTruthy(ist.id),
|
||||
assertTruthy(task.issueProviderId),
|
||||
),
|
||||
).pipe(
|
||||
map((issueUrl) => ({
|
||||
...ist,
|
||||
href: issueUrl,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
).pipe(
|
||||
catchError((e) => {
|
||||
IssueLog.err(e);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'Failed to load subtasks for Jira Issue',
|
||||
});
|
||||
return of(undefined);
|
||||
}),
|
||||
)
|
||||
: of(undefined),
|
||||
),
|
||||
);
|
||||
|
||||
jiraRelatedIssues$: Observable<JiraRelatedIssue[] | undefined> = combineLatest([
|
||||
this._task$,
|
||||
this._issue$,
|
||||
]).pipe(
|
||||
switchMap(([task, issue]) =>
|
||||
issue.relatedIssues?.length
|
||||
? forkJoin(
|
||||
...issue.relatedIssues.map((ist: any) => {
|
||||
return from(
|
||||
this._jiraCommonInterfacesService.issueLink(
|
||||
assertTruthy(ist.key),
|
||||
assertTruthy(task.issueProviderId),
|
||||
),
|
||||
).pipe(
|
||||
map((issueUrl) => ({
|
||||
...ist,
|
||||
href: issueUrl,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
)
|
||||
: of(undefined),
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
@Input('issue') set issueIn(i: JiraIssue) {
|
||||
this.issue = i;
|
||||
this._issue$.next(i);
|
||||
try {
|
||||
this.description = i && i.description && j2m.to_markdown(i.description);
|
||||
} catch (e) {
|
||||
IssueLog.log(i);
|
||||
devError(e);
|
||||
this.description = (i && i.description) || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
@Input('task') set taskIn(v: TaskWithSubTasks) {
|
||||
this.task = v;
|
||||
this._task$.next(v);
|
||||
}
|
||||
|
||||
hideUpdates(): void {
|
||||
if (!this.task) {
|
||||
throw new Error('No task');
|
||||
}
|
||||
if (!this.issue) {
|
||||
throw new Error('No issue');
|
||||
}
|
||||
this._taskService.markIssueUpdatesAsRead(this.task.id);
|
||||
}
|
||||
|
||||
trackByIndex(i: number, p: JiraComment): number {
|
||||
return i;
|
||||
}
|
||||
|
||||
get sortedComments(): JiraComment[] {
|
||||
if (!this.issue?.comments) {
|
||||
return [];
|
||||
}
|
||||
return [...this.issue.comments].sort((a, b) => a.created.localeCompare(b.created));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
<ng-container>
|
||||
@if (task()?.issueWasUpdated) {
|
||||
<div
|
||||
@expand
|
||||
style="text-align: center"
|
||||
>
|
||||
<button
|
||||
(click)="hideUpdates()"
|
||||
color="accent"
|
||||
mat-raised-button
|
||||
>
|
||||
{{ T.F.OPEN_PROJECT.ISSUE_CONTENT.MARK_AS_CHECKED | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="issue-table">
|
||||
<tr>
|
||||
<th>{{ T.F.OPEN_PROJECT.ISSUE_CONTENT.SUMMARY | translate }}</th>
|
||||
<td>
|
||||
<a
|
||||
[href]="issue?.url"
|
||||
target="_blank"
|
||||
><strong>{{ issue?.subject }} #{{ issue?.id }}</strong></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (issue?._embedded.type?.name) {
|
||||
<tr>
|
||||
<th>{{ T.F.OPEN_PROJECT.ISSUE_CONTENT.TYPE | translate }}</th>
|
||||
<td>{{ issue?._embedded.type.name }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?._embedded.status) {
|
||||
<tr>
|
||||
<th>{{ T.F.OPEN_PROJECT.ISSUE_CONTENT.STATUS | translate }}</th>
|
||||
<td>
|
||||
<span
|
||||
class="dot"
|
||||
[style.backgroud-color]="issue?._embedded.status.color"
|
||||
></span
|
||||
>{{ issue?._embedded.status.name }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?._embedded.assignee?.name) {
|
||||
<tr>
|
||||
<th>{{ T.F.OPEN_PROJECT.ISSUE_CONTENT.ASSIGNEE | translate }}</th>
|
||||
<td>{{ issue?._embedded.assignee.name }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (issue?.description?.raw || issue?.description?.html) {
|
||||
<tr>
|
||||
<th>{{ T.F.OPEN_PROJECT.ISSUE_CONTENT.DESCRIPTION | translate }}</th>
|
||||
<td class="issue-description">
|
||||
<div
|
||||
[data]="issue?.description.raw || issue?.description.html"
|
||||
class="description markdown"
|
||||
markdown
|
||||
></div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
|
||||
@if (issue) {
|
||||
<h3>{{ T.F.OPEN_PROJECT.ISSUE_CONTENT.ATTACHMENTS | translate }}</h3>
|
||||
@if (isUploading) {
|
||||
<div class="loading-wrapper">
|
||||
<mat-progress-bar
|
||||
color="primary"
|
||||
mode="indeterminate"
|
||||
></mat-progress-bar>
|
||||
</div>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
(click)="fileInput.click()"
|
||||
class="upload-btn"
|
||||
>
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
{{ T.F.OPEN_PROJECT.ISSUE_CONTENT.UPLOAD_ATTACHMENT | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
#fileInput
|
||||
hidden
|
||||
(change)="onFileUpload($event)"
|
||||
/>
|
||||
|
||||
@if (getTaskAttachments(issue); as attachments) {
|
||||
@if (attachments && attachments.length > 0) {
|
||||
<div class="attachment-dl-list">
|
||||
@for (attachment of attachments; track attachment.id) {
|
||||
<div class="attachment">
|
||||
<a
|
||||
[href]="attachment.path"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="attachment-inner"
|
||||
>
|
||||
<mat-icon class="attachment-icon">{{ attachment.icon }}</mat-icon>
|
||||
<span class="attachment-title">{{ attachment.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<!-- <div *ngIf="issue?.comments">-->
|
||||
<!-- <div-->
|
||||
<!-- *ngFor="let comment of (issue?.comments|sort:'created_at'); trackBy: trackByIndex"-->
|
||||
<!-- class="comment"-->
|
||||
<!-- >-->
|
||||
<!-- <!–<img [src]="comment.author.avatarUrl"–>-->
|
||||
<!-- <!–class="author-avatar">–>-->
|
||||
<!-- <div class="name-and-comment-content">-->
|
||||
<!-- <div>-->
|
||||
<!-- <span class="author-name">{{comment.user?.login}}</span>-->
|
||||
<!-- <span class="when"-->
|
||||
<!-- >{{T.F.OPEN_PROJECT.ISSUE_CONTENT.AT|translate}}-->
|
||||
<!-- {{comment.created_at|date:'short'}}</span-->
|
||||
<!-- >-->
|
||||
<!-- </div>-->
|
||||
<!-- <div-->
|
||||
<!-- *ngIf="comment.body"-->
|
||||
<!-- [innerHTML]="comment?.body|markdown|async"-->
|
||||
<!-- class="markdown"-->
|
||||
<!-- ></div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!--<pre><code>-->
|
||||
<!--{{issue?|json}}-->
|
||||
<!--</code></pre>-->
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
@use '../../../../../../styles/_globals.scss' as *;
|
||||
|
||||
// table styled by ./src/styles/components/issue-table.scss
|
||||
|
||||
.table-wrapper {
|
||||
margin: var(--s2);
|
||||
margin-bottom: var(--s);
|
||||
|
||||
.upload-btn {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.attachment-dl-list {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.issue-description {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comment {
|
||||
display: flex;
|
||||
margin-bottom: var(--s);
|
||||
padding-top: var(--s);
|
||||
border-top: 1px dashed var(--extra-border-color);
|
||||
|
||||
border-color: var(--extra-border-color);
|
||||
|
||||
.author-avatar {
|
||||
margin-right: var(--s);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.when {
|
||||
margin-left: var(--s);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.name-and-comment-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
border: 1px dashed var(--extra-border-color);
|
||||
padding: var(--s);
|
||||
}
|
||||
}
|
||||
|
||||
.write-a-comment {
|
||||
// needed for the box-shadow inside scrollable container
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
//
|
||||
// import { OpenProjectIssueContentComponent } from './open-project-issue-content.component';
|
||||
//
|
||||
// describe('OpenProjectIssueContentComponent', () => {
|
||||
// let component: OpenProjectIssueContentComponent;
|
||||
// let fixture: ComponentFixture<OpenProjectIssueContentComponent>;
|
||||
//
|
||||
// beforeEach(async(() => {
|
||||
// TestBed.configureTestingModule({
|
||||
// declarations: [OpenProjectIssueContentComponent]
|
||||
// })
|
||||
// .compileComponents();
|
||||
// }));
|
||||
//
|
||||
// beforeEach(() => {
|
||||
// fixture = TestBed.createComponent(OpenProjectIssueContentComponent);
|
||||
// component = fixture.componentInstance;
|
||||
// fixture.detectChanges();
|
||||
// });
|
||||
//
|
||||
// it('should create', () => {
|
||||
// expect(component).toBeTruthy();
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('OpenProjectIssueContentComponent moment replacement', () => {
|
||||
describe('date time formatting for file names', () => {
|
||||
it('should format current date time as YYYYMMDD_HHmmss', () => {
|
||||
// Test the formatting pattern
|
||||
const testDate = new Date('2023-10-15T14:30:45.123Z');
|
||||
const pad = (num: number): string => String(num).padStart(2, '0');
|
||||
const year = testDate.getFullYear();
|
||||
const month = pad(testDate.getMonth() + 1);
|
||||
const day = pad(testDate.getDate());
|
||||
const hours = pad(testDate.getHours());
|
||||
const minutes = pad(testDate.getMinutes());
|
||||
const seconds = pad(testDate.getSeconds());
|
||||
const dateTime = `${year}${month}${day}_${hours}${minutes}${seconds}`;
|
||||
|
||||
// Check format pattern (not exact value due to timezone)
|
||||
expect(dateTime).toMatch(/^\d{8}_\d{6}$/);
|
||||
expect(dateTime.length).toBe(15);
|
||||
});
|
||||
|
||||
it('should pad single digit values correctly', () => {
|
||||
// Test with a date that has single digit values
|
||||
const testDate = new Date(2023, 0, 5, 9, 8, 7); // Jan 5, 2023, 09:08:07
|
||||
const pad = (num: number): string => String(num).padStart(2, '0');
|
||||
const year = testDate.getFullYear();
|
||||
const month = pad(testDate.getMonth() + 1);
|
||||
const day = pad(testDate.getDate());
|
||||
const hours = pad(testDate.getHours());
|
||||
const minutes = pad(testDate.getMinutes());
|
||||
const seconds = pad(testDate.getSeconds());
|
||||
const dateTime = `${year}${month}${day}_${hours}${minutes}${seconds}`;
|
||||
|
||||
expect(dateTime).toBe('20230105_090807');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
input,
|
||||
inject,
|
||||
ChangeDetectorRef,
|
||||
} from '@angular/core';
|
||||
import { TaskWithSubTasks } from '../../../../tasks/task.model';
|
||||
import { OpenProjectWorkPackage } from '../open-project-issue.model';
|
||||
import { expandAnimation } from '../../../../../ui/animations/expand.ani';
|
||||
import { T } from '../../../../../t.const';
|
||||
import { TaskService } from '../../../../tasks/task.service';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MarkdownComponent } from 'ngx-markdown';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TaskAttachment } from '../../../../tasks/task-attachment/task-attachment.model';
|
||||
import { IssueProviderService } from '../../../issue-provider.service';
|
||||
import { mapOpenProjectAttachmentToTaskAttachment } from '../open-project-issue-map.util';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { OpenProjectApiService } from '../open-project-api.service';
|
||||
import { SnackService } from '../../../../../core/snack/snack.service';
|
||||
import { formatDateTimeForFilename } from '../../../../../util/format-date-time-for-filename';
|
||||
|
||||
@Component({
|
||||
selector: 'open-project-issue-content',
|
||||
templateUrl: './open-project-issue-content.component.html',
|
||||
styleUrls: ['./open-project-issue-content.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandAnimation],
|
||||
imports: [MatButton, MarkdownComponent, TranslatePipe, MatIcon, MatProgressBar],
|
||||
})
|
||||
export class OpenProjectIssueContentComponent {
|
||||
private readonly _taskService = inject(TaskService);
|
||||
private readonly _issueProviderService = inject(IssueProviderService);
|
||||
private readonly _openProjectApiService = inject(OpenProjectApiService);
|
||||
private readonly _snackService = inject(SnackService);
|
||||
private readonly _cdr = inject(ChangeDetectorRef);
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// This input is used in a control flow expression (e.g. `@if` or `*ngIf`)
|
||||
// and migrating would break narrowing currently.
|
||||
@Input() issue?: OpenProjectWorkPackage;
|
||||
readonly task = input<TaskWithSubTasks>();
|
||||
|
||||
attachments: TaskAttachment[] | undefined;
|
||||
isUploading: boolean = false;
|
||||
|
||||
T: typeof T = T;
|
||||
|
||||
hideUpdates(): void {
|
||||
const task = this.task();
|
||||
if (!task) {
|
||||
throw new Error('No task');
|
||||
}
|
||||
if (!this.issue) {
|
||||
throw new Error('No issue');
|
||||
}
|
||||
this._taskService.markIssueUpdatesAsRead(task.id);
|
||||
}
|
||||
|
||||
trackByIndex(i: number, p: any): number {
|
||||
return i;
|
||||
}
|
||||
|
||||
getTaskAttachments(issue: OpenProjectWorkPackage): TaskAttachment[] {
|
||||
if (this.attachments) {
|
||||
return this.attachments;
|
||||
}
|
||||
|
||||
this.attachments = issue?._embedded?.attachments._embedded.elements.map((att) =>
|
||||
mapOpenProjectAttachmentToTaskAttachment(att),
|
||||
);
|
||||
|
||||
return this.attachments;
|
||||
}
|
||||
|
||||
async onFileUpload(event: Event): Promise<void> {
|
||||
this.isUploading = true;
|
||||
|
||||
const element = event.target as HTMLInputElement;
|
||||
const file = element.files?.[0];
|
||||
const currentTask = this.task();
|
||||
|
||||
if (!file || !currentTask || !currentTask.issueId || !currentTask.issueProviderId) {
|
||||
if (!file) {
|
||||
this._snackService.open({ type: 'ERROR', msg: T.F.OPEN_PROJECT.S.ERR_NO_FILE });
|
||||
}
|
||||
|
||||
element.value = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = await this._issueProviderService
|
||||
.getCfgOnce$(currentTask.issueProviderId, 'OPEN_PROJECT')
|
||||
.toPromise();
|
||||
|
||||
const dateTime = formatDateTimeForFilename();
|
||||
const fileExtension = file?.name.split('.').pop();
|
||||
|
||||
let fileName = `${dateTime}_${currentTask.issueId}.${fileExtension}`;
|
||||
|
||||
const fileNamePrefix = cfg.metadata?.['attachments']?.['fileNamePrefix'];
|
||||
if (fileNamePrefix) {
|
||||
fileName = `${fileNamePrefix}_${fileName}`;
|
||||
}
|
||||
|
||||
const newAttachment = await this._openProjectApiService
|
||||
.uploadAttachment$(cfg, currentTask.issueId, file, fileName)
|
||||
.toPromise();
|
||||
|
||||
element.value = '';
|
||||
this.isUploading = false;
|
||||
this.attachments?.push(mapOpenProjectAttachmentToTaskAttachment(newAttachment));
|
||||
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
<ng-container>
|
||||
<h3>
|
||||
<a
|
||||
[href]="issue?.url"
|
||||
target="_blank"
|
||||
>
|
||||
{{ issue?.tracker.name }} #{{ issue?.id }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="issue-table">
|
||||
<tr>
|
||||
<th>{{ T.F.REDMINE.ISSUE_CONTENT.AUTHOR | translate }}</th>
|
||||
<td>{{ issue?.author.name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>{{ T.F.REDMINE.ISSUE_CONTENT.STATUS | translate }}</th>
|
||||
<td>{{ issue?.status.name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>{{ T.F.REDMINE.ISSUE_CONTENT.PRIORITY | translate }}</th>
|
||||
<td>{{ issue?.priority.name }}</td>
|
||||
</tr>
|
||||
|
||||
@if (issue?.description) {
|
||||
<tr>
|
||||
<th>{{ T.F.REDMINE.ISSUE_CONTENT.DESCRIPTION | translate }}</th>
|
||||
<td>{{ issue?.description }}</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (task()?.issueWasUpdated) {
|
||||
<div
|
||||
@expand
|
||||
style="text-align: center"
|
||||
>
|
||||
<button
|
||||
(click)="hideUpdates()"
|
||||
color="accent"
|
||||
mat-raised-button
|
||||
>
|
||||
{{ T.F.REDMINE.ISSUE_CONTENT.MARK_AS_CHECKED | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { Component, ChangeDetectionStrategy, Input, input, inject } from '@angular/core';
|
||||
import { TaskWithSubTasks } from 'src/app/features/tasks/task.model';
|
||||
import { TaskService } from 'src/app/features/tasks/task.service';
|
||||
import { T } from 'src/app/t.const';
|
||||
import { expandAnimation } from 'src/app/ui/animations/expand.ani';
|
||||
import { RedmineIssue } from '../redmine-issue.model';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'redmine-issue-content',
|
||||
templateUrl: './redmine-issue-content.component.html',
|
||||
styleUrls: ['./redmine-issue-content.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandAnimation],
|
||||
imports: [MatButton, TranslatePipe],
|
||||
})
|
||||
export class RedmineIssueContentComponent {
|
||||
private readonly _taskService = inject(TaskService);
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// This input is used in a control flow expression (e.g. `@if` or `*ngIf`)
|
||||
// and migrating would break narrowing currently.
|
||||
@Input() issue?: RedmineIssue;
|
||||
readonly task = input<TaskWithSubTasks>();
|
||||
|
||||
T: typeof T = T;
|
||||
|
||||
hideUpdates(): void {
|
||||
const task = this.task();
|
||||
if (!task) {
|
||||
throw new Error('No task');
|
||||
}
|
||||
if (!this.issue) {
|
||||
throw new Error('No issue');
|
||||
}
|
||||
this._taskService.markIssueUpdatesAsRead(task.id);
|
||||
}
|
||||
|
||||
trackByIndex(i: number, p: any): number {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// import { TestBed } from '@angular/core/testing';
|
||||
// import { provideMockActions } from '@ngrx/effects/testing';
|
||||
// import { Observable } from 'rxjs';
|
||||
//
|
||||
// import { UnlinkAllTasksOnProviderDeletionEffects } from './unlink-all-tasks-on-provider-deletion.effects';
|
||||
//
|
||||
// describe('UnlinkAllTasksOnProviderDeletionEffects', () => {
|
||||
// let actions$: Observable<any>;
|
||||
// let effects: UnlinkAllTasksOnProviderDeletionEffects;
|
||||
//
|
||||
// beforeEach(() => {
|
||||
// TestBed.configureTestingModule({
|
||||
// providers: [
|
||||
// UnlinkAllTasksOnProviderDeletionEffects,
|
||||
// provideMockActions(() => actions$)
|
||||
// ]
|
||||
// });
|
||||
//
|
||||
// effects = TestBed.inject(UnlinkAllTasksOnProviderDeletionEffects);
|
||||
// });
|
||||
//
|
||||
// it('should be created', () => {
|
||||
// expect(effects).toBeTruthy();
|
||||
// });
|
||||
// });
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/**
|
||||
* @deprecated This file is no longer used. Issue provider deletion archive cleanup has
|
||||
* been consolidated into ArchiveOperationHandlerEffects for unified handling of both
|
||||
* local and remote operations.
|
||||
*
|
||||
* @see ArchiveOperationHandler._handleDeleteIssueProvider
|
||||
* @see ArchiveOperationHandler._handleDeleteIssueProviders
|
||||
* @see ArchiveOperationHandlerEffects at:
|
||||
* src/app/op-log/apply/archive-operation-handler.effects.ts
|
||||
*
|
||||
* All archive-affecting operations are now routed through ArchiveOperationHandler,
|
||||
* which is the SINGLE SOURCE OF TRUTH for archive storage operations.
|
||||
*
|
||||
* This file is kept temporarily for reference and should be deleted in a future cleanup.
|
||||
*/
|
||||
|
|
@ -10,15 +10,10 @@
|
|||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
user-select: none;
|
||||
min-width: 320px;
|
||||
max-width: 320px;
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
|
||||
@media (min-width: 370px) {
|
||||
min-width: 352px;
|
||||
max-width: 352px;
|
||||
}
|
||||
|
||||
:host-context([dir='rtl']) {
|
||||
direction: rtl;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ export const selectAllTasksDueToday = createSelector(
|
|||
const allDue: (TaskWithDueTime | TaskWithDueDay)[] = (
|
||||
plannerState.days[todayStr] || []
|
||||
)
|
||||
.map((tid) => taskState.entities[tid] as TaskWithDueDay)
|
||||
.map((tid) => taskState.entities[tid])
|
||||
// there is a chance that the task is not in the store anymore
|
||||
.filter((t) => !!t);
|
||||
.filter((t): t is TaskWithDueDay => !!t);
|
||||
|
||||
// Use Set for O(1) lookup
|
||||
const allDueIds = new Set(allDue.map((t) => t.id));
|
||||
|
|
|
|||
|
|
@ -263,31 +263,32 @@ describe('createBlockerBlocks()', () => {
|
|||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
|
||||
expect(r.length).toEqual(2);
|
||||
// Entries are sorted by start time within each block
|
||||
expect(r).toEqual([
|
||||
{
|
||||
start: getDateTimeFromClockString('15:00', 0),
|
||||
end: getDateTimeFromClockString('19:00', 0),
|
||||
entries: [
|
||||
{
|
||||
data: fakeTasks[0],
|
||||
end: fakeTasks[0].dueWithTime,
|
||||
start: fakeTasks[0].dueWithTime,
|
||||
type: 'ScheduledTask',
|
||||
},
|
||||
{
|
||||
data: fakeTasks[3],
|
||||
data: fakeTasks[3], // 15:00
|
||||
end: fakeTasks[3].dueWithTime! + hours(1),
|
||||
start: fakeTasks[3].dueWithTime,
|
||||
type: 'ScheduledTask',
|
||||
},
|
||||
{
|
||||
data: fakeTasks[4],
|
||||
data: fakeTasks[4], // 15:30
|
||||
end: fakeTasks[4].dueWithTime! + hours(2.5),
|
||||
start: fakeTasks[4].dueWithTime,
|
||||
type: 'ScheduledTask',
|
||||
},
|
||||
{
|
||||
data: fakeTasks[1],
|
||||
data: fakeTasks[0], // 16:00
|
||||
end: fakeTasks[0].dueWithTime,
|
||||
start: fakeTasks[0].dueWithTime,
|
||||
type: 'ScheduledTask',
|
||||
},
|
||||
{
|
||||
data: fakeTasks[1], // 17:00
|
||||
end: fakeTasks[1].dueWithTime! + hours(2),
|
||||
start: fakeTasks[1].dueWithTime,
|
||||
type: 'ScheduledTask',
|
||||
|
|
@ -546,6 +547,7 @@ describe('createBlockerBlocks()', () => {
|
|||
);
|
||||
|
||||
expect(r.length).toEqual(30);
|
||||
// Entries are sorted by start time within merged blocks
|
||||
expect(r).toEqual([
|
||||
{
|
||||
end: getDateTimeFromClockString('09:00', new Date(1620259200000)),
|
||||
|
|
@ -560,9 +562,9 @@ describe('createBlockerBlocks()', () => {
|
|||
data: {
|
||||
_showSubTasksMode: 2,
|
||||
attachments: [],
|
||||
created: 1620239185383,
|
||||
created: 1620227624280,
|
||||
doneOn: null,
|
||||
id: 'RlHPfiXYk',
|
||||
id: 'LayqneCZ0',
|
||||
isDone: false,
|
||||
issueAttachmentNr: null,
|
||||
issueId: null,
|
||||
|
|
@ -572,19 +574,19 @@ describe('createBlockerBlocks()', () => {
|
|||
issueWasUpdated: null,
|
||||
notes: '',
|
||||
parentId: null,
|
||||
dueWithTime: getDateTimeFromClockString('23:00', new Date(1620172800000)),
|
||||
dueWithTime: getDateTimeFromClockString('21:00', new Date(1620172800000)),
|
||||
projectId: null,
|
||||
reminderId: 'wctU7fdUV',
|
||||
reminderId: 'NkonFINlM',
|
||||
repeatCfgId: null,
|
||||
subTaskIds: [],
|
||||
tagIds: ['TODAY'],
|
||||
timeEstimate: 0,
|
||||
timeSpent: 1999,
|
||||
timeSpentOnDay: { '2021-05-05': 1999 },
|
||||
title: 'XXX',
|
||||
timeEstimate: 1800000,
|
||||
timeSpent: 0,
|
||||
timeSpentOnDay: {},
|
||||
title: 'Sched1',
|
||||
},
|
||||
end: getDateTimeFromClockString('23:00', new Date(1620172800000)),
|
||||
start: getDateTimeFromClockString('23:00', new Date(1620172800000)),
|
||||
end: getDateTimeFromClockString('21:30', new Date(1620172800000)),
|
||||
start: getDateTimeFromClockString('21:00', new Date(1620172800000)),
|
||||
type: 'ScheduledTask',
|
||||
},
|
||||
{
|
||||
|
|
@ -622,9 +624,9 @@ describe('createBlockerBlocks()', () => {
|
|||
data: {
|
||||
_showSubTasksMode: 2,
|
||||
attachments: [],
|
||||
created: 1620227624280,
|
||||
created: 1620239185383,
|
||||
doneOn: null,
|
||||
id: 'LayqneCZ0',
|
||||
id: 'RlHPfiXYk',
|
||||
isDone: false,
|
||||
issueAttachmentNr: null,
|
||||
issueId: null,
|
||||
|
|
@ -634,19 +636,19 @@ describe('createBlockerBlocks()', () => {
|
|||
issueWasUpdated: null,
|
||||
notes: '',
|
||||
parentId: null,
|
||||
dueWithTime: getDateTimeFromClockString('21:00', new Date(1620172800000)),
|
||||
dueWithTime: getDateTimeFromClockString('23:00', new Date(1620172800000)),
|
||||
projectId: null,
|
||||
reminderId: 'NkonFINlM',
|
||||
reminderId: 'wctU7fdUV',
|
||||
repeatCfgId: null,
|
||||
subTaskIds: [],
|
||||
tagIds: ['TODAY'],
|
||||
timeEstimate: 1800000,
|
||||
timeSpent: 0,
|
||||
timeSpentOnDay: {},
|
||||
title: 'Sched1',
|
||||
timeEstimate: 0,
|
||||
timeSpent: 1999,
|
||||
timeSpentOnDay: { '2021-05-05': 1999 },
|
||||
title: 'XXX',
|
||||
},
|
||||
end: getDateTimeFromClockString('21:30', new Date(1620172800000)),
|
||||
start: getDateTimeFromClockString('21:00', new Date(1620172800000)),
|
||||
end: getDateTimeFromClockString('23:00', new Date(1620172800000)),
|
||||
start: getDateTimeFromClockString('23:00', new Date(1620172800000)),
|
||||
type: 'ScheduledTask',
|
||||
},
|
||||
],
|
||||
|
|
@ -1063,4 +1065,475 @@ describe('createBlockerBlocks()', () => {
|
|||
] as any);
|
||||
});
|
||||
});
|
||||
|
||||
describe('block merging algorithm', () => {
|
||||
// These tests specifically verify the merging behavior of overlapping blocks
|
||||
// to ensure the algorithm correctly handles all edge cases
|
||||
|
||||
it('should return empty array when no tasks provided', () => {
|
||||
const r = createSortedBlockerBlocks([], [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should return single block for single task', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
title: 'Single Task',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0),
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].entries.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('should keep non-overlapping blocks separate', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
title: 'Task 1',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('09:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
title: 'Task 2',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('14:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'S3',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
title: 'Task 3',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('20:00', 0),
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(3);
|
||||
// Verify sorted by start time
|
||||
expect(r[0].start).toBeLessThan(r[1].start);
|
||||
expect(r[1].start).toBeLessThan(r[2].start);
|
||||
});
|
||||
|
||||
it('should merge two directly adjacent blocks', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
title: 'Task 1',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
title: 'Task 2',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('11:00', 0), // Starts exactly when S1 ends
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].start).toEqual(getDateTimeFromClockString('10:00', 0));
|
||||
expect(r[0].end).toEqual(getDateTimeFromClockString('12:00', 0));
|
||||
expect(r[0].entries.length).toEqual(2);
|
||||
});
|
||||
|
||||
it('should merge block completely contained within another', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(4),
|
||||
title: 'Long Task',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('09:00', 0), // 9:00 - 13:00
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
title: 'Short Task Inside',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0), // 10:00 - 11:00 (inside S1)
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0));
|
||||
expect(r[0].end).toEqual(getDateTimeFromClockString('13:00', 0));
|
||||
expect(r[0].entries.length).toEqual(2);
|
||||
});
|
||||
|
||||
it('should merge chain of overlapping blocks (A->B->C)', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(2),
|
||||
title: 'Task A',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('09:00', 0), // 9:00 - 11:00
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(2),
|
||||
title: 'Task B',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:30', 0), // 10:30 - 12:30 (overlaps A)
|
||||
},
|
||||
{
|
||||
id: 'S3',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(2),
|
||||
title: 'Task C',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('12:00', 0), // 12:00 - 14:00 (overlaps B)
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0));
|
||||
expect(r[0].end).toEqual(getDateTimeFromClockString('14:00', 0));
|
||||
expect(r[0].entries.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should merge tasks given in reverse chronological order', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S3',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
title: 'Task C (last)',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('11:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
title: 'Task B (middle)',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
title: 'Task A (first)',
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('09:00', 0),
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0));
|
||||
expect(r[0].end).toEqual(getDateTimeFromClockString('12:00', 0));
|
||||
expect(r[0].entries.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should handle multiple separate groups of overlapping blocks', () => {
|
||||
const fakeTasks: any[] = [
|
||||
// Group 1: 9:00 - 11:00
|
||||
{
|
||||
id: 'G1A',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('09:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'G1B',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0),
|
||||
},
|
||||
// Gap from 11:00 - 14:00
|
||||
// Group 2: 14:00 - 16:00
|
||||
{
|
||||
id: 'G2A',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('14:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'G2B',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('15:00', 0),
|
||||
},
|
||||
// Gap from 16:00 - 20:00
|
||||
// Group 3: 20:00 - 22:00
|
||||
{
|
||||
id: 'G3A',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('20:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'G3B',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('21:00', 0),
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(3);
|
||||
expect(r[0].entries.length).toEqual(2);
|
||||
expect(r[1].entries.length).toEqual(2);
|
||||
expect(r[2].entries.length).toEqual(2);
|
||||
});
|
||||
|
||||
it('should handle many overlapping blocks efficiently', () => {
|
||||
// Create 50 overlapping tasks to test performance
|
||||
const fakeTasks: any[] = [];
|
||||
const baseTime = getDateTimeFromClockString('09:00', 0);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const offset = i * minutes(30); // Start every 30 min
|
||||
fakeTasks.push({
|
||||
id: `S${i}`,
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(2), // 2 hour tasks
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: baseTime + offset,
|
||||
});
|
||||
}
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
// All should merge into one block since they overlap
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].entries.length).toEqual(50);
|
||||
// First task starts at 9:00, last task (49 * 30min later = 24.5h) + 2h duration
|
||||
expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0));
|
||||
});
|
||||
|
||||
it('should handle blocks with same start time', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(2),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0), // Same start
|
||||
},
|
||||
{
|
||||
id: 'S3',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(3),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0), // Same start
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].start).toEqual(getDateTimeFromClockString('10:00', 0));
|
||||
expect(r[0].end).toEqual(getDateTimeFromClockString('13:00', 0)); // Longest task
|
||||
expect(r[0].entries.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should handle blocks with same end time', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(3),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('09:00', 0), // Ends at 12:00
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(2),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0), // Ends at 12:00
|
||||
},
|
||||
{
|
||||
id: 'S3',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('11:00', 0), // Ends at 12:00
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0));
|
||||
expect(r[0].end).toEqual(getDateTimeFromClockString('12:00', 0));
|
||||
expect(r[0].entries.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should handle zero-duration tasks', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: 0, // Zero duration
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0),
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].entries.length).toEqual(2);
|
||||
});
|
||||
|
||||
it('should correctly sort output by start time', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'Later',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('15:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'Earlier',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('09:00', 0),
|
||||
},
|
||||
{
|
||||
id: 'Middle',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(1),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('12:00', 0),
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(3);
|
||||
expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0));
|
||||
expect(r[1].start).toEqual(getDateTimeFromClockString('12:00', 0));
|
||||
expect(r[2].start).toEqual(getDateTimeFromClockString('15:00', 0));
|
||||
});
|
||||
|
||||
it('should merge calendar events with scheduled tasks when overlapping', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(2),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('10:00', 0), // 10:00 - 12:00
|
||||
},
|
||||
];
|
||||
const icalEventMap: ScheduleCalendarMapEntry[] = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
id: 'CalEvent',
|
||||
calProviderId: 'PR',
|
||||
start: getDateTimeFromClockString('11:00', 0), // 11:00 - 12:00 (overlaps task)
|
||||
title: 'Calendar Event',
|
||||
duration: hours(1),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(
|
||||
fakeTasks,
|
||||
[],
|
||||
icalEventMap,
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
);
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].entries.length).toEqual(2);
|
||||
expect(r[0].start).toEqual(getDateTimeFromClockString('10:00', 0));
|
||||
expect(r[0].end).toEqual(getDateTimeFromClockString('12:00', 0));
|
||||
});
|
||||
|
||||
it('should handle overlapping blocks across midnight', () => {
|
||||
const fakeTasks: any[] = [
|
||||
{
|
||||
id: 'S1',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(3),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('22:00', 0), // 22:00 - 01:00 next day
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
subTaskIds: [],
|
||||
timeSpent: 0,
|
||||
timeEstimate: hours(2),
|
||||
reminderId: 'xxx',
|
||||
dueWithTime: getDateTimeFromClockString('23:30', 0), // 23:30 - 01:30 next day
|
||||
},
|
||||
];
|
||||
const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0);
|
||||
expect(r.length).toEqual(1);
|
||||
expect(r[0].start).toEqual(getDateTimeFromClockString('22:00', 0));
|
||||
// End time should be 01:30 next day
|
||||
expect(r[0].end).toEqual(getDateTimeFromClockString('23:30', 0) + hours(2));
|
||||
expect(r[0].entries.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -283,29 +283,40 @@ const createBlockerBlocksForCalendarEvents = (
|
|||
return blockedBlocks;
|
||||
};
|
||||
|
||||
// NOTE: we recursively merge all overlapping blocks
|
||||
// TODO find more effective algorithm
|
||||
// Merge overlapping blocks using an efficient O(n log n) algorithm
|
||||
// Sort by start time, then single-pass merge of consecutive overlapping blocks
|
||||
const mergeBlocksRecursively = (blockedBlocks: BlockedBlock[]): BlockedBlock[] => {
|
||||
for (const blockedBlock of blockedBlocks) {
|
||||
let wasMergedInner = false;
|
||||
for (const blockedBlockInner of blockedBlocks) {
|
||||
if (
|
||||
blockedBlockInner !== blockedBlock &&
|
||||
isOverlappingBlock(blockedBlockInner, blockedBlock)
|
||||
) {
|
||||
blockedBlock.start = Math.min(blockedBlockInner.start, blockedBlock.start);
|
||||
blockedBlock.end = Math.max(blockedBlockInner.end, blockedBlock.end);
|
||||
blockedBlock.entries = blockedBlock.entries.concat(blockedBlockInner.entries);
|
||||
blockedBlocks.splice(blockedBlocks.indexOf(blockedBlockInner), 1);
|
||||
wasMergedInner = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (wasMergedInner) {
|
||||
return mergeBlocksRecursively(blockedBlocks);
|
||||
if (blockedBlocks.length <= 1) {
|
||||
return blockedBlocks;
|
||||
}
|
||||
|
||||
// Sort by start time
|
||||
blockedBlocks.sort((a, b) => a.start - b.start);
|
||||
|
||||
const merged: BlockedBlock[] = [blockedBlocks[0]];
|
||||
|
||||
for (let i = 1; i < blockedBlocks.length; i++) {
|
||||
const current = blockedBlocks[i];
|
||||
const last = merged[merged.length - 1];
|
||||
|
||||
// Check if current block overlaps or touches the last merged block
|
||||
// Two blocks overlap/touch if current.start <= last.end
|
||||
if (current.start <= last.end) {
|
||||
// Merge: extend end time and combine entries
|
||||
last.end = Math.max(last.end, current.end);
|
||||
last.entries = last.entries.concat(current.entries);
|
||||
} else {
|
||||
// No overlap, add as new block
|
||||
merged.push(current);
|
||||
}
|
||||
}
|
||||
return blockedBlocks;
|
||||
|
||||
// Sort entries within each merged block by start time for consistent ordering
|
||||
for (const block of merged) {
|
||||
block.entries.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const isOverlappingBlock = (
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const NEXT_BTN = {
|
|||
type: 'next',
|
||||
};
|
||||
|
||||
const CANCEL_BTN: any = (shepherdService: ShepherdService) => ({
|
||||
const CANCEL_BTN = (shepherdService: ShepherdService): Step.StepOptionsButton => ({
|
||||
classes: SECONDARY_CLASSES,
|
||||
text: 'No thanks',
|
||||
action: () => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
} @else {
|
||||
<mat-icon>check_box_outline_blank</mat-icon>
|
||||
}
|
||||
@if (tag.icon && isEmojiIcon(tag.icon)) {
|
||||
@if (tag.icon && tag.isEmojiIcon) {
|
||||
<span
|
||||
[style.color]="tag.color || tag.theme.primary"
|
||||
class="tag-ico tag-ico-emoji"
|
||||
|
|
|
|||
|
|
@ -94,9 +94,5 @@ export class TagToggleMenuListComponent {
|
|||
});
|
||||
}
|
||||
|
||||
isEmojiIcon(icon: string | undefined): boolean {
|
||||
return icon ? isSingleEmoji(icon) : false;
|
||||
}
|
||||
|
||||
protected readonly T = T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'
|
|||
import { DEFAULT_TASK, Task } from '../task.model';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
import { T } from '../../../t.const';
|
||||
import { IS_ANDROID_WEB_VIEW_TOKEN } from '../../../util/is-android-web-view';
|
||||
|
||||
describe('TaskReminderEffects', () => {
|
||||
let actions$: Observable<Action>;
|
||||
|
|
@ -47,6 +48,7 @@ describe('TaskReminderEffects', () => {
|
|||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{ provide: LocaleDatePipe, useValue: datePipeSpy },
|
||||
{ provide: IS_ANDROID_WEB_VIEW_TOKEN, useValue: false },
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -298,3 +300,111 @@ describe('TaskReminderEffects', () => {
|
|||
// NOTE: Tests for removeTaskReminderTrigger1$ were removed because the effect no longer exists.
|
||||
// The reminder removal is now handled differently in the task-shared scheduling meta-reducer.
|
||||
});
|
||||
|
||||
// NOTE: Full Android integration tests for cancelNativeReminderOnUnschedule$ require
|
||||
// androidInterface to be initialized (only happens in Android WebView).
|
||||
// These tests verify the filter behavior with the injection token.
|
||||
|
||||
describe('TaskReminderEffects - cancelNativeReminderOnUnschedule$ filter', () => {
|
||||
let actions$: Observable<Action>;
|
||||
let effects: TaskReminderEffects;
|
||||
|
||||
describe('when IS_ANDROID_WEB_VIEW_TOKEN is true', () => {
|
||||
beforeEach(() => {
|
||||
const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
|
||||
const taskServiceSpy = jasmine.createSpyObj('TaskService', ['getByIdOnce$']);
|
||||
const storeSpy = jasmine.createSpyObj('Store', ['dispatch']);
|
||||
const datePipeSpy = jasmine.createSpyObj('LocaleDatePipe', ['transform']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TaskReminderEffects,
|
||||
provideMockActions(() => actions$),
|
||||
{ provide: SnackService, useValue: snackServiceSpy },
|
||||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{ provide: LocaleDatePipe, useValue: datePipeSpy },
|
||||
{ provide: IS_ANDROID_WEB_VIEW_TOKEN, useValue: true },
|
||||
],
|
||||
});
|
||||
|
||||
effects = TestBed.inject(TaskReminderEffects);
|
||||
});
|
||||
|
||||
it('should pass through unscheduleTask action when on Android', (done) => {
|
||||
const action = TaskSharedActions.unscheduleTask({ id: 'task-1' });
|
||||
actions$ = of(action);
|
||||
|
||||
// Verify the effect emits (filter passes)
|
||||
effects.cancelNativeReminderOnUnschedule$.subscribe({
|
||||
next: () => {
|
||||
// Action passed through the filter - this is the expected behavior
|
||||
expect(true).toBe(true);
|
||||
done();
|
||||
},
|
||||
error: () => {
|
||||
// The tap may throw because androidInterface is undefined,
|
||||
// but the filter still passed, which is what we're testing
|
||||
expect(true).toBe(true);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through dismissReminderOnly action when on Android', (done) => {
|
||||
const action = TaskSharedActions.dismissReminderOnly({ id: 'task-1' });
|
||||
actions$ = of(action);
|
||||
|
||||
effects.cancelNativeReminderOnUnschedule$.subscribe({
|
||||
next: () => {
|
||||
expect(true).toBe(true);
|
||||
done();
|
||||
},
|
||||
error: () => {
|
||||
expect(true).toBe(true);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when IS_ANDROID_WEB_VIEW_TOKEN is false', () => {
|
||||
beforeEach(() => {
|
||||
const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
|
||||
const taskServiceSpy = jasmine.createSpyObj('TaskService', ['getByIdOnce$']);
|
||||
const storeSpy = jasmine.createSpyObj('Store', ['dispatch']);
|
||||
const datePipeSpy = jasmine.createSpyObj('LocaleDatePipe', ['transform']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TaskReminderEffects,
|
||||
provideMockActions(() => actions$),
|
||||
{ provide: SnackService, useValue: snackServiceSpy },
|
||||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{ provide: LocaleDatePipe, useValue: datePipeSpy },
|
||||
{ provide: IS_ANDROID_WEB_VIEW_TOKEN, useValue: false },
|
||||
],
|
||||
});
|
||||
|
||||
effects = TestBed.inject(TaskReminderEffects);
|
||||
});
|
||||
|
||||
it('should filter out actions when not on Android', (done) => {
|
||||
const action = TaskSharedActions.unscheduleTask({ id: 'task-1' });
|
||||
actions$ = of(action);
|
||||
|
||||
let emitted = false;
|
||||
effects.cancelNativeReminderOnUnschedule$.subscribe({
|
||||
next: () => {
|
||||
emitted = true;
|
||||
},
|
||||
complete: () => {
|
||||
// Filter should block the action, so nothing should emit
|
||||
expect(emitted).toBe(false);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { SnackService } from '../../../core/snack/snack.service';
|
|||
import { TaskService } from '../task.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import {
|
||||
IS_ANDROID_WEB_VIEW,
|
||||
IS_ANDROID_WEB_VIEW_TOKEN,
|
||||
} from '../../../util/is-android-web-view';
|
||||
import { androidInterface } from '../../android/android-interface';
|
||||
import { generateNotificationId } from '../../android/android-notification-id.util';
|
||||
|
||||
|
|
@ -20,6 +23,7 @@ export class TaskReminderEffects {
|
|||
private _taskService = inject(TaskService);
|
||||
private _store = inject(Store);
|
||||
private _datePipe = inject(LocaleDatePipe);
|
||||
private _isAndroidWebView = inject(IS_ANDROID_WEB_VIEW_TOKEN);
|
||||
|
||||
snack$ = createEffect(
|
||||
() =>
|
||||
|
|
@ -132,6 +136,25 @@ export class TaskReminderEffects {
|
|||
{ dispatch: false },
|
||||
);
|
||||
|
||||
// Cancel native Android reminders when reminder is removed or dismissed
|
||||
// Uses injection token with filter for testability (unlike other Android effects)
|
||||
cancelNativeReminderOnUnschedule$ = createEffect(
|
||||
() =>
|
||||
this._localActions$.pipe(
|
||||
ofType(TaskSharedActions.unscheduleTask, TaskSharedActions.dismissReminderOnly),
|
||||
filter(() => this._isAndroidWebView),
|
||||
tap(({ id }) => {
|
||||
try {
|
||||
const notificationId = generateNotificationId(id);
|
||||
androidInterface.cancelNativeReminder?.(notificationId);
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel native reminder:', e);
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
// Cancel native Android reminders when tasks are deleted
|
||||
cancelNativeRemindersOnDelete$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
|
|
|
|||
|
|
@ -500,8 +500,8 @@ export const selectAllTasksWithDueTime = createSelector(
|
|||
selectAllTasks,
|
||||
(tasks: Task[]): TaskWithDueTime[] => {
|
||||
return tasks.filter(
|
||||
(task) => typeof task.dueWithTime === 'number',
|
||||
) as TaskWithDueTime[];
|
||||
(task): task is TaskWithDueTime => !!task && typeof task.dueWithTime === 'number',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -509,8 +509,10 @@ export const selectAllTasksWithDueTimeSorted = createSelector(
|
|||
selectAllTasks,
|
||||
(tasks: Task[]): TaskWithDueTime[] => {
|
||||
return tasks
|
||||
.filter((task) => typeof task.dueWithTime === 'number')
|
||||
.sort((a, b) => a.dueWithTime! - b.dueWithTime!) as TaskWithDueTime[];
|
||||
.filter(
|
||||
(task): task is TaskWithDueTime => !!task && typeof task.dueWithTime === 'number',
|
||||
)
|
||||
.sort((a, b) => a.dueWithTime - b.dueWithTime);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export class TaskContextMenuInnerComponent implements AfterViewInit {
|
|||
|
||||
isAdvancedControls = input<boolean>(false);
|
||||
todayList = toSignal(this._store.select(selectTodayTaskIds), { initialValue: [] });
|
||||
isOnTodayList = computed(() => this.todayList().includes(this.task.id));
|
||||
isOnTodayList = computed(() => this.task && this.todayList().includes(this.task.id));
|
||||
readonly isTimeTrackingEnabled = computed(
|
||||
() => this._globalConfigService.cfg()?.appFeatures.isTimeTrackingEnabled,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,11 @@ export class TaskShortcutService {
|
|||
ev.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (checkKeyCombo(ev, keys.taskUnschedule)) {
|
||||
this._handleTaskShortcut(focusedTaskId, 'unschedule');
|
||||
ev.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (checkKeyCombo(ev, keys.taskToggleDone)) {
|
||||
this._handleTaskShortcut(focusedTaskId, 'toggleDoneKeyboard');
|
||||
ev.preventDefault();
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
/* main advantages:
|
||||
--------------------
|
||||
* less writes to a much smaller model
|
||||
* also likely good for performance in some cases, since view models are more granular
|
||||
* can be flushed to database less often while other data can be written whenever something is changed (maybe debounced to every 10s)
|
||||
* disk space should remain about the same (additional id props vs previous sub props like workStart etc. on every task and project)
|
||||
* theoretically we could also use the model to collect sessions to keep track of when time is spent during the day
|
||||
*/
|
||||
|
||||
// we could maybe store time as seconds with 3 digits precision that can be rounded when being moved to archive
|
||||
|
||||
/* NEW SYNC MODEL OUTLINE
|
||||
|
||||
A
|
||||
=======
|
||||
3 +x files
|
||||
frequent: main sync file and tasks
|
||||
base: projects, tags, settings, boards, etc.
|
||||
currentArchive: current year (min 365 days)
|
||||
oldArchive: all previous years data
|
||||
|
||||
B
|
||||
=======
|
||||
2 +x files
|
||||
main: as is
|
||||
currentArchive: current year (min 365 + 2 days – +2 to work around timezone stuff)
|
||||
oldArchive: all previous years data
|
||||
|
||||
How to handle flushing to oldArchive?
|
||||
* keep track of last old archive update in main file
|
||||
* if it's more than 1 year ago, flush to oldArchive
|
||||
*/
|
||||
|
||||
export interface TrackingDay {
|
||||
// flushed to archive after 14 days
|
||||
// since apart from quick history and worklog we don't need more than that
|
||||
workStart: {
|
||||
[projectOrTagId: string]: number;
|
||||
};
|
||||
workEnd: {
|
||||
[projectOrTagId: string]: number;
|
||||
};
|
||||
breakTime: number;
|
||||
}
|
||||
|
||||
export interface TrackingTaskState {
|
||||
// flushed to archive together with task
|
||||
[taskId: string]: {
|
||||
[dateStr: string]: number;
|
||||
};
|
||||
|
||||
// ...merged with (rarely changing) archive data
|
||||
}
|
||||
|
||||
export interface TrackingState {
|
||||
task: TrackingTaskState;
|
||||
other: {
|
||||
[dateStr: string]: TrackingDay;
|
||||
// ... merged with (rarely changing) archive data to map
|
||||
};
|
||||
}
|
||||
|
||||
// ^ same for archive and non archive
|
||||
|
|
@ -208,6 +208,58 @@ describe('workContext selectors', () => {
|
|||
planned: [],
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should handle missing task entities gracefully (issue #6014)', () => {
|
||||
// Simulate state where todayIds contains IDs that don't exist in entities
|
||||
// This can happen during sync or when tasks are deleted
|
||||
const existingTask = {
|
||||
id: 'existing',
|
||||
subTaskIds: [],
|
||||
tagIds: [],
|
||||
isDone: false,
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
// Create task state with only one task but multiple IDs
|
||||
const taskState = {
|
||||
ids: ['existing', 'missing1', 'missing2'],
|
||||
entities: {
|
||||
existing: existingTask,
|
||||
// missing1 and missing2 are not in entities
|
||||
},
|
||||
} as any;
|
||||
|
||||
// This should not throw "Cannot read properties of undefined"
|
||||
const result = selectTimelineTasks.projector(['existing', 'missing1'], taskState);
|
||||
|
||||
// Should only include the existing task, not crash on missing ones
|
||||
expect(result.unPlanned.length).toBe(1);
|
||||
expect(result.unPlanned[0].id).toBe('existing');
|
||||
expect(result.planned.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle task with dueWithTime when some entities are missing (issue #6014)', () => {
|
||||
const taskWithDueTime = {
|
||||
id: 'withDueTime',
|
||||
subTaskIds: [],
|
||||
tagIds: [],
|
||||
isDone: false,
|
||||
dueWithTime: Date.now(),
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const taskState = {
|
||||
ids: ['withDueTime', 'missingTask'],
|
||||
entities: {
|
||||
withDueTime: taskWithDueTime,
|
||||
// missingTask is not in entities
|
||||
},
|
||||
} as any;
|
||||
|
||||
// This should not throw "Cannot read properties of undefined (reading 'dueWithTime')"
|
||||
const result = selectTimelineTasks.projector(['missingTask'], taskState);
|
||||
|
||||
expect(result.planned.length).toBe(1);
|
||||
expect(result.planned[0].id).toBe('withDueTime');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectTodayTaskIds (virtual tag pattern - uses dueDay)', () => {
|
||||
|
|
|
|||
|
|
@ -321,7 +321,8 @@ export const selectTimelineTasks = createSelector(
|
|||
} => {
|
||||
const allPlannedTasks: TaskWithDueTime[] = [];
|
||||
s.ids
|
||||
.map((id) => s.entities[id] as Task)
|
||||
.map((id) => s.entities[id])
|
||||
.filter((t): t is Task => !!t)
|
||||
.forEach((t) => {
|
||||
if (!t.isDone) {
|
||||
if (t.dueWithTime) {
|
||||
|
|
@ -335,9 +336,9 @@ export const selectTimelineTasks = createSelector(
|
|||
return {
|
||||
planned: allPlannedTasks,
|
||||
unPlanned: todayIds
|
||||
.map((id) => {
|
||||
return mapSubTasksToTask(s.entities[id] as Task, s) as TaskWithSubTasks;
|
||||
})
|
||||
.map((id) => s.entities[id])
|
||||
.filter((t): t is Task => !!t)
|
||||
.map((t) => mapSubTasksToTask(t, s) as TaskWithSubTasks)
|
||||
.filter((t) => !t.isDone && !allPlannedIdSet.has(t.id)),
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -222,10 +222,8 @@ export class WorklogService {
|
|||
(await this._taskService.taskFeatureState$.pipe(first()).toPromise()) ||
|
||||
createEmptyEntity();
|
||||
|
||||
// console.time('calcTime');
|
||||
const { completeStateForWorkContext, nonArchiveTaskIds } =
|
||||
getCompleteStateForWorkContext(workContext, taskState, archive);
|
||||
// console.timeEnd('calcTime');
|
||||
|
||||
const workStartEndForWorkContext =
|
||||
await this._timeTrackingService.getLegacyWorkStartEndForWorkContext(workContext);
|
||||
|
|
@ -257,10 +255,8 @@ export class WorklogService {
|
|||
(await this._taskService.taskFeatureState$.pipe(first()).toPromise()) ||
|
||||
createEmptyEntity();
|
||||
|
||||
// console.time('calcTime');
|
||||
const { completeStateForWorkContext, nonArchiveTaskIds } =
|
||||
getCompleteStateForWorkContext(workContext, taskState, archive);
|
||||
// console.timeEnd('calcTime');
|
||||
|
||||
const workStartEndForWorkContext =
|
||||
await this._timeTrackingService.getLegacyWorkStartEndForWorkContext(workContext);
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ describe('EncryptionPasswordChangeService', () => {
|
|||
TEST_VECTOR_CLOCK,
|
||||
jasmine.any(Number), // CURRENT_SCHEMA_VERSION
|
||||
true, // isPayloadEncrypted
|
||||
jasmine.any(String), // opId (UUID)
|
||||
);
|
||||
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
|||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import { CURRENT_SCHEMA_VERSION } from '../../op-log/persistence/schema-migration.service';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { uuidv7 } from '../../util/uuid-v7';
|
||||
|
||||
/**
|
||||
* Service for changing the encryption password for SuperSync.
|
||||
|
|
@ -90,6 +91,7 @@ export class EncryptionPasswordChangeService {
|
|||
vectorClock,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
true, // isPayloadEncrypted
|
||||
uuidv7(), // opId - server must use this ID
|
||||
);
|
||||
|
||||
if (!response.accepted) {
|
||||
|
|
@ -139,6 +141,7 @@ export class EncryptionPasswordChangeService {
|
|||
vectorClock,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
true,
|
||||
uuidv7(), // opId - server must use this ID
|
||||
);
|
||||
|
||||
if (recoveryResponse.accepted) {
|
||||
|
|
|
|||
|
|
@ -390,7 +390,8 @@ describe('SyncWrapperService', () => {
|
|||
|
||||
await service.sync();
|
||||
|
||||
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
|
||||
// setSyncStatus is called with 'SYNCING' at start, but should NOT be called with 'IN_SYNC' on error
|
||||
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
|
||||
});
|
||||
|
||||
it('should set ERROR and return HANDLED_ERROR when upload has rejected ops with "Payload too complex"', async () => {
|
||||
|
|
|
|||
|
|
@ -162,8 +162,15 @@ export class SyncWrapperService {
|
|||
return 'HANDLED_ERROR';
|
||||
}
|
||||
this._isSyncInProgress$.next(true);
|
||||
// Set SYNCING status so ImmediateUploadService knows not to interfere
|
||||
this._providerManager.setSyncStatus('SYNCING');
|
||||
return this._sync().finally(() => {
|
||||
this._isSyncInProgress$.next(false);
|
||||
// Safeguard: if _sync() threw or completed without setting a final status,
|
||||
// reset from SYNCING to UNKNOWN_OR_CHANGED to avoid getting stuck in SYNCING state
|
||||
if (this._providerManager.isSyncInProgress) {
|
||||
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { deleteTag, deleteTags } from '../../features/tag/store/tag.actions';
|
|||
import { TimeTrackingService } from '../../features/time-tracking/time-tracking.service';
|
||||
import { loadAllData } from '../../root-store/meta/load-all-data.action';
|
||||
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
|
||||
import { OpType } from '../core/operation.types';
|
||||
|
||||
describe('isArchiveAffectingAction', () => {
|
||||
it('should return true for moveToArchive action', () => {
|
||||
|
|
@ -964,6 +965,171 @@ describe('ArchiveOperationHandler', () => {
|
|||
mockTaskArchiveService.removeAllArchiveTasksForProject,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('safety guard for empty archive overwrite', () => {
|
||||
const existingArchiveYoung = createArchiveModelForTest([
|
||||
'existing-1',
|
||||
'existing-2',
|
||||
]);
|
||||
const existingArchiveOld = createArchiveModelForTest(['old-existing-1']);
|
||||
const emptyArchive: ArchiveModel = {
|
||||
task: { ids: [], entities: {} },
|
||||
timeTracking: { project: {}, tag: {} },
|
||||
lastTimeTrackingFlush: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockArchiveDbAdapter.loadArchiveYoung.and.returnValue(
|
||||
Promise.resolve(existingArchiveYoung),
|
||||
);
|
||||
mockArchiveDbAdapter.loadArchiveOld.and.returnValue(
|
||||
Promise.resolve(existingArchiveOld),
|
||||
);
|
||||
});
|
||||
|
||||
describe('SYNC_IMPORT', () => {
|
||||
it('should throw error when empty archiveYoung would overwrite non-empty archive', async () => {
|
||||
const action = {
|
||||
type: loadAllData.type,
|
||||
appDataComplete: { archiveYoung: emptyArchive },
|
||||
meta: { isPersistent: true, isRemote: true, opType: OpType.SyncImport },
|
||||
} as unknown as PersistentAction;
|
||||
|
||||
await expectAsync(service.handleOperation(action)).toBeRejectedWithError(
|
||||
/SAFETY GUARD.*archiveYoung.*bug in SYNC_IMPORT/,
|
||||
);
|
||||
expect(mockArchiveDbAdapter.saveArchiveYoung).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when empty archiveOld would overwrite non-empty archive', async () => {
|
||||
const action = {
|
||||
type: loadAllData.type,
|
||||
appDataComplete: { archiveOld: emptyArchive },
|
||||
meta: { isPersistent: true, isRemote: true, opType: OpType.SyncImport },
|
||||
} as unknown as PersistentAction;
|
||||
|
||||
await expectAsync(service.handleOperation(action)).toBeRejectedWithError(
|
||||
/SAFETY GUARD.*archiveOld.*bug in SYNC_IMPORT/,
|
||||
);
|
||||
expect(mockArchiveDbAdapter.saveArchiveOld).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow writing non-empty archive over existing archive', async () => {
|
||||
const newArchive = createArchiveModelForTest(['new-task']);
|
||||
const action = {
|
||||
type: loadAllData.type,
|
||||
appDataComplete: { archiveYoung: newArchive },
|
||||
meta: { isPersistent: true, isRemote: true, opType: OpType.SyncImport },
|
||||
} as unknown as PersistentAction;
|
||||
|
||||
await service.handleOperation(action);
|
||||
|
||||
expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalledWith(
|
||||
newArchive,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACKUP_IMPORT', () => {
|
||||
let originalConfirm: typeof window.confirm;
|
||||
|
||||
beforeEach(() => {
|
||||
originalConfirm = window.confirm;
|
||||
window.confirm = jasmine.createSpy('confirm').and.returnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
|
||||
it('should show confirm dialog and throw if user cancels (archiveYoung)', async () => {
|
||||
(window.confirm as jasmine.Spy).and.returnValue(false);
|
||||
|
||||
const action = {
|
||||
type: loadAllData.type,
|
||||
appDataComplete: { archiveYoung: emptyArchive },
|
||||
meta: { isPersistent: true, isRemote: true, opType: OpType.BackupImport },
|
||||
} as unknown as PersistentAction;
|
||||
|
||||
await expectAsync(service.handleOperation(action)).toBeRejectedWithError(
|
||||
/User cancelled backup import/,
|
||||
);
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
jasmine.stringContaining('2 archived tasks'),
|
||||
);
|
||||
expect(mockArchiveDbAdapter.saveArchiveYoung).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show confirm dialog and proceed if user confirms (archiveYoung)', async () => {
|
||||
(window.confirm as jasmine.Spy).and.returnValue(true);
|
||||
|
||||
const action = {
|
||||
type: loadAllData.type,
|
||||
appDataComplete: { archiveYoung: emptyArchive },
|
||||
meta: { isPersistent: true, isRemote: true, opType: OpType.BackupImport },
|
||||
} as unknown as PersistentAction;
|
||||
|
||||
await service.handleOperation(action);
|
||||
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalledWith(
|
||||
emptyArchive,
|
||||
);
|
||||
});
|
||||
|
||||
it('should show confirm dialog and throw if user cancels (archiveOld)', async () => {
|
||||
(window.confirm as jasmine.Spy).and.returnValue(false);
|
||||
|
||||
const action = {
|
||||
type: loadAllData.type,
|
||||
appDataComplete: { archiveOld: emptyArchive },
|
||||
meta: { isPersistent: true, isRemote: true, opType: OpType.BackupImport },
|
||||
} as unknown as PersistentAction;
|
||||
|
||||
await expectAsync(service.handleOperation(action)).toBeRejectedWithError(
|
||||
/User cancelled backup import/,
|
||||
);
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
jasmine.stringContaining('1 old archived tasks'),
|
||||
);
|
||||
expect(mockArchiveDbAdapter.saveArchiveOld).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show confirm dialog and proceed if user confirms (archiveOld)', async () => {
|
||||
(window.confirm as jasmine.Spy).and.returnValue(true);
|
||||
|
||||
const action = {
|
||||
type: loadAllData.type,
|
||||
appDataComplete: { archiveOld: emptyArchive },
|
||||
meta: { isPersistent: true, isRemote: true, opType: OpType.BackupImport },
|
||||
} as unknown as PersistentAction;
|
||||
|
||||
await service.handleOperation(action);
|
||||
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(mockArchiveDbAdapter.saveArchiveOld).toHaveBeenCalledWith(
|
||||
emptyArchive,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show confirm dialog when archive is not empty', async () => {
|
||||
const newArchive = createArchiveModelForTest(['new-task']);
|
||||
|
||||
const action = {
|
||||
type: loadAllData.type,
|
||||
appDataComplete: { archiveYoung: newArchive },
|
||||
meta: { isPersistent: true, isRemote: true, opType: OpType.BackupImport },
|
||||
} as unknown as PersistentAction;
|
||||
|
||||
await service.handleOperation(action);
|
||||
|
||||
expect(window.confirm).not.toHaveBeenCalled();
|
||||
expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalledWith(
|
||||
newArchive,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unhandled actions', () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { ArchiveCompressionService } from '../../features/archive/archive-compre
|
|||
import { loadAllData } from '../../root-store/meta/load-all-data.action';
|
||||
import { ArchiveModel } from '../../features/archive/archive.model';
|
||||
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
|
||||
import { OpType } from '../core/operation.types';
|
||||
|
||||
/**
|
||||
* Creates an empty ArchiveModel with default values.
|
||||
|
|
@ -504,47 +505,71 @@ export class ArchiveOperationHandler {
|
|||
// Write archiveYoung if present in the import data
|
||||
if (archiveYoung !== undefined) {
|
||||
if (hasExistingYoung && isIncomingYoungEmpty) {
|
||||
OpLog.warn(
|
||||
'[ArchiveOperationHandler] SAFETY GUARD: Refusing to overwrite non-empty archiveYoung with empty archive. ' +
|
||||
'This may indicate a bug in SYNC_IMPORT creation (using sync instead of async snapshot).',
|
||||
{ existingTaskCount: originalArchiveYoung?.task?.ids?.length ?? 0 },
|
||||
);
|
||||
} else {
|
||||
await this._archiveDbAdapter.saveArchiveYoung(archiveYoung);
|
||||
const existingCount = originalArchiveYoung?.task?.ids?.length ?? 0;
|
||||
if (action.meta.opType === OpType.SyncImport) {
|
||||
throw new Error(
|
||||
'[ArchiveOperationHandler] SAFETY GUARD: Refusing to overwrite non-empty archiveYoung with empty archive. ' +
|
||||
'This is a bug in SYNC_IMPORT creation (using sync instead of async snapshot). ' +
|
||||
`Existing task count: ${existingCount}`,
|
||||
);
|
||||
} else if (action.meta.opType === OpType.BackupImport) {
|
||||
const confirmed = window.confirm(
|
||||
`This backup has empty archives, but you have ${existingCount} archived tasks locally. ` +
|
||||
'Restoring will delete your archived data. Continue?',
|
||||
);
|
||||
if (!confirmed) {
|
||||
throw new Error(
|
||||
'[ArchiveOperationHandler] User cancelled backup import to preserve archives',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this._archiveDbAdapter.saveArchiveYoung(archiveYoung);
|
||||
}
|
||||
|
||||
// Write archiveOld if present in the import data
|
||||
if (archiveOld !== undefined) {
|
||||
if (hasExistingOld && isIncomingOldEmpty) {
|
||||
OpLog.warn(
|
||||
'[ArchiveOperationHandler] SAFETY GUARD: Refusing to overwrite non-empty archiveOld with empty archive. ' +
|
||||
'This may indicate a bug in SYNC_IMPORT creation (using sync instead of async snapshot).',
|
||||
{ existingTaskCount: originalArchiveOld?.task?.ids?.length ?? 0 },
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
await this._archiveDbAdapter.saveArchiveOld(archiveOld);
|
||||
} catch (e) {
|
||||
// Attempt rollback: restore archiveYoung to original state
|
||||
OpLog.err(
|
||||
'[ArchiveOperationHandler] archiveOld write failed, attempting rollback...',
|
||||
e,
|
||||
const existingCount = originalArchiveOld?.task?.ids?.length ?? 0;
|
||||
if (action.meta.opType === OpType.SyncImport) {
|
||||
throw new Error(
|
||||
'[ArchiveOperationHandler] SAFETY GUARD: Refusing to overwrite non-empty archiveOld with empty archive. ' +
|
||||
'This is a bug in SYNC_IMPORT creation (using sync instead of async snapshot). ' +
|
||||
`Existing task count: ${existingCount}`,
|
||||
);
|
||||
try {
|
||||
if (originalArchiveYoung !== undefined) {
|
||||
await this._archiveDbAdapter.saveArchiveYoung(originalArchiveYoung);
|
||||
OpLog.log('[ArchiveOperationHandler] Rollback successful');
|
||||
}
|
||||
} catch (rollbackErr) {
|
||||
OpLog.err(
|
||||
'[ArchiveOperationHandler] Rollback FAILED - archive may be inconsistent',
|
||||
rollbackErr,
|
||||
} else if (action.meta.opType === OpType.BackupImport) {
|
||||
const confirmed = window.confirm(
|
||||
`This backup has empty old archives, but you have ${existingCount} old archived tasks locally. ` +
|
||||
'Restoring will delete your old archived data. Continue?',
|
||||
);
|
||||
if (!confirmed) {
|
||||
throw new Error(
|
||||
'[ArchiveOperationHandler] User cancelled backup import to preserve archives',
|
||||
);
|
||||
}
|
||||
throw e; // Re-throw original error
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this._archiveDbAdapter.saveArchiveOld(archiveOld);
|
||||
} catch (e) {
|
||||
// Attempt rollback: restore archiveYoung to original state
|
||||
OpLog.err(
|
||||
'[ArchiveOperationHandler] archiveOld write failed, attempting rollback...',
|
||||
e,
|
||||
);
|
||||
try {
|
||||
if (originalArchiveYoung !== undefined) {
|
||||
await this._archiveDbAdapter.saveArchiveYoung(originalArchiveYoung);
|
||||
OpLog.log('[ArchiveOperationHandler] Rollback successful');
|
||||
}
|
||||
} catch (rollbackErr) {
|
||||
OpLog.err(
|
||||
'[ArchiveOperationHandler] Rollback FAILED - archive may be inconsistent',
|
||||
rollbackErr,
|
||||
);
|
||||
}
|
||||
throw e; // Re-throw original error
|
||||
}
|
||||
}
|
||||
|
||||
OpLog.log(
|
||||
|
|
|
|||
|
|
@ -457,9 +457,9 @@ describe('bulkHydrationMetaReducer', () => {
|
|||
);
|
||||
|
||||
// 20k should be roughly 4x 5k (linear scaling)
|
||||
// We allow up to 8x to account for overhead and cache effects
|
||||
// We allow up to 12x to account for overhead, cache effects, and CI variability
|
||||
const ratio = time20k / time5k;
|
||||
expect(ratio).toBeLessThan(8);
|
||||
expect(ratio).toBeLessThan(12);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue