Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
533 changes: 533 additions & 0 deletions docs/unlock-indicator.md

Large diffs are not rendered by default.

28 changes: 15 additions & 13 deletions projects/v3/src/app/components/activity/activity.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Submission } from '@v3/services/assessment.service';
import { NotificationsService } from '@v3/services/notifications.service';
import { BrowserStorageService } from '@v3/services/storage.service';
import { UtilsService } from '@v3/services/utils.service';
import { takeUntil } from 'rxjs/operators';
import { takeUntil, distinctUntilChanged } from 'rxjs/operators';

@Component({
selector: 'app-activity',
Expand Down Expand Up @@ -58,9 +58,20 @@ export class ActivityComponent implements OnInit, OnChanges, OnDestroy {
ngOnInit() {
this.leadImage = this.storageService.getUser().programImage;
this.unlockIndicatorService.unlockedTasks$
.pipe(takeUntil(this.unsubscribe$))
.pipe(
takeUntil(this.unsubscribe$),
distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr))
)
.subscribe({
next: res => this.resetTaskIndicator(res)
next: res => {
// only update the visual indicators, don't clear anything
if (this.activity?.id) {
const activityUnlocks = this.unlockIndicatorService.getTasksByActivity(this.activity);
this.resetTaskIndicator(activityUnlocks);
} else {
this.resetTaskIndicator(res);
}
}
});
}

Expand Down Expand Up @@ -112,18 +123,9 @@ export class ActivityComponent implements OnInit, OnChanges, OnDestroy {
this.cannotAccessTeamActivity.emit(this.isForTeamOnly);
});

// clear viewed unlocked indicator
// update unlock indicators when activity changes, but don't clear
const unlockedTasks = this.unlockIndicatorService.getTasksByActivity(this.activity);
this.resetTaskIndicator(unlockedTasks);
if (unlockedTasks.length === 0) {
const clearedActivities = this.unlockIndicatorService.clearActivity(this.activity.id);
clearedActivities.forEach((activity) => {
this.notificationsService
.markTodoItemAsDone(activity)
.pipe(takeUntil(this.unsubscribe$))
.subscribe();
});
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion projects/v3/src/app/components/img/img.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export class ImgComponent implements OnChanges {
}
}

ngOnChanges(changes: SimpleChanges) {
ngOnChanges(changes:
) {
// In development mode, replace the Practera file URL with a proxied URL to avoid CORS issues.
const hostname = window.location.hostname;
const isLocalhost = /(^localhost$)|(^127\.)|(^::1$)/.test(hostname);
Expand Down
116 changes: 116 additions & 0 deletions projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export class ActivityDesktopPage {
};
scrolSubject = new BehaviorSubject(null);

// track navigation state for unlock indicator clearing
private fromHome: boolean = false;

@ViewChild(AssessmentComponent) assessmentComponent!: AssessmentComponent;
@ViewChild('scrollableTaskContent', { static: false }) scrollableTaskContent: {el: HTMLIonColElement};
@ViewChild(TopicComponent) topicComponent: TopicComponent;
Expand Down Expand Up @@ -108,6 +111,10 @@ export class ActivityDesktopPage {
// cleanup previous session
this.componentCleanupService.triggerCleanup();

// capture navigation state early before it's lost
const navigation = this.router.getCurrentNavigation();
this.fromHome = navigation?.extras?.state?.fromHome || false;

this.activityService.activity$
.pipe(
filter((res) => res?.id === +this.route.snapshot.paramMap.get('id')),
Expand Down Expand Up @@ -306,6 +313,114 @@ export class ActivityDesktopPage {
}

this.activity = res;
// only clear pure activity-level unlock indicators onLoad of activity when navigating from Home
this._clearPureActivityIndicatorIfFromHome(res.id);
}

/**
* clears activity-level unlock indicators only when navigating from Home page
*/
private _clearPureActivityIndicatorIfFromHome(activityId: number): void {
if (!activityId) { return; }

// check if user is navigating from Home page using stored state
if (!this.fromHome) {
return;
}

this._clearActivityLevelIndicators(activityId);
}

/**
* checks if activity-level indicators should be cleared after task completion
* called when user completes tasks within the activity
*/
private _checkActivityLevelClearingAfterTaskCompletion(): void {
if (!this.activity?.id) {
return;
}

// use timeout to allow unlock indicator service to update after task completion
setTimeout(() => {
this._clearActivityLevelIndicators(this.activity.id);
}, 500);
}

private async _clearActivityLevelIndicators(activityId: number): Promise<void> {
if (!activityId) { return; }

try {
const currentTodoItems = this.notificationsService.getCurrentTodoItems();
let entries = this.unlockIndicatorService.getTasksByActivityId(activityId);

// retry fetching todo items if no entries found
if (entries?.length === 0) {
await firstValueFrom(this.notificationsService.getTodoItems());
entries = this.unlockIndicatorService.getTasksByActivityId(activityId);
}

// Double confirmed, no indicators for this activity
// if (entries?.length > 0 && entries.every(e => e.taskId === undefined)) {
// // handles server-side duplicates and hierarchy
// const result = this.unlockIndicatorService.clearByActivityIdWithDuplicates(activityId, currentTodoItems);

// this.unlockIndicatorService.markDuplicatesAsDone(result, this.notificationsService, 'activity');
if (!entries || entries.length === 0) {
return;
}

// Separate activity-level and task-level indicators
const activityLevelEntries = entries.filter(e => e.taskId === undefined);
const taskLevelEntries = entries.filter(e => e.taskId !== undefined);

// Only clear activity-level indicators if:
// 1. There are activity-level entries to clear
// 2. The activity is clearable (no task-level children)
if (activityLevelEntries.length > 0 && taskLevelEntries.length === 0) {
// Activity is clearable - no task children remain
const result = this.unlockIndicatorService.clearByActivityIdWithDuplicates(activityId, currentTodoItems);

// Mark the original cleared activity-level indicators as done
result.clearedUnlocks?.forEach(todo => {
this.notificationsService.markTodoItemAsDone(todo).subscribe(() => {
// eslint-disable-next-line no-console
console.info("Marked activity indicator as done (activity page)", todo);
});
});

// Mark all duplicate TodoItems as done (bulk operation)
if (result.duplicatesToMark.length > 0) {
this.notificationsService.markMultipleTodoItemsAsDone(result.duplicatesToMark);
}

// Handle cascade milestone clearing
result.cascadeMilestones.forEach(milestoneData => {
if (milestoneData.duplicatesToMark.length > 0) {
const milestoneMarkingOps = this.notificationsService.markMultipleTodoItemsAsDone(milestoneData.duplicatesToMark);
}
});

// Note: The fallback at line 364-367 was already handling this, but only as a fallback
return;
}

// If we couldn't clear via standard approach, try robust clearing
// This handles inaccurate data where relationships might be broken
if (activityLevelEntries.length > 0) {
const relatedIndicators = this.unlockIndicatorService.findRelatedIndicators('activity', activityId);
const pureActivityIndicators = relatedIndicators.filter(r => r.taskId === undefined);

// Only clear if activity is truly clearable (no tasks)
if (pureActivityIndicators.length > 0 && taskLevelEntries.length === 0) {
const cleared = this.unlockIndicatorService.clearRelatedIndicators('activity', activityId);
cleared?.forEach(todo => {
this.notificationsService.markTodoItemAsDone(todo).subscribe();
});
}
}
} catch (e) {
console.error('[unlock-indicator] cleanup failed for activity', activityId, e);
}
}

/**
Expand Down Expand Up @@ -340,6 +455,7 @@ export class ActivityDesktopPage {
}

await this.activityService.goToTask(task);
this._checkActivityLevelClearingAfterTaskCompletion();
this.isLoadingAssessment = false;
} catch (error) {
this.isLoadingAssessment = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { ActivatedRoute, Router } from '@angular/router';
import { ActivityService, Task, Activity } from '@v3/services/activity.service';
import { AssessmentService, Submission } from '@v3/services/assessment.service';
import { filter } from 'rxjs/operators';
import { UnlockIndicatorService } from '@v3/app/services/unlock-indicator.service';
import { NotificationsService } from '@v3/app/services/notifications.service';

@Component({
selector: 'app-activity-mobile',
Expand All @@ -18,18 +20,58 @@ export class ActivityMobilePage implements OnInit {
private router: Router,
private activityService: ActivityService,
private assessmentService: AssessmentService,
private unlockIndicatorService: UnlockIndicatorService,
private notificationsService: NotificationsService,
) { }

ngOnInit() {
this.activityService.activity$
.pipe(filter(res => res?.id === +this.route.snapshot.paramMap.get('id')))
.subscribe(res => this.activity = res);
.subscribe(res => {
this.activity = res;
if (res?.id) {
this.clearPureActivityIndicator(res.id);
}
});
this.assessmentService.submission$.subscribe(res => this.submission = res);
this.route.params.subscribe(params => {
this.activityService.getActivity(+params.id, false);
});
}

/**
* Clear activity-level-only unlock indicators when entering the activity page.
* Uses robust clearing to handle inaccurate unlock indicator data.
*/
private clearPureActivityIndicator(activityId: number) {
if (!activityId) { return; }

try {
// First try the standard approach
const entries = this.unlockIndicatorService.getTasksByActivityId(activityId);
if (entries?.length > 0 && entries.every(e => e.taskId === undefined)) {
const cleared = this.unlockIndicatorService.clearByActivityId(activityId);
cleared?.forEach(todo => this.notificationsService.markTodoItemAsDone(todo).subscribe());
return;
}

// If standard approach didn't find anything, try robust clearing for inaccurate data
const relatedIndicators = this.unlockIndicatorService.findRelatedIndicators('activity', activityId);
if (relatedIndicators?.length > 0) {
// Only clear if they are pure activity-level (no task-specific entries)
const pureActivityIndicators = relatedIndicators.filter(r => r.taskId === undefined);
if (pureActivityIndicators.length > 0) {
const cleared = this.unlockIndicatorService.clearRelatedIndicators('activity', activityId);
cleared?.forEach(todo => this.notificationsService.markTodoItemAsDone(todo).subscribe());
}
}
} catch (e) {
// swallow to avoid breaking page enter; optional logging can be added under dev flag
// eslint-disable-next-line no-console
console.debug('[unlock-indicator] cleanup skipped for activity', activityId, e);
}
}

goToTask(task: Task) {
this.activityService.goToTask(task, false);
switch (task.type) {
Expand Down
68 changes: 44 additions & 24 deletions projects/v3/src/app/pages/home/home.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Achievement,
AchievementService,
} from '@v3/app/services/achievement.service';
import { NavigationStateService } from '@v3/app/services/navigation-state.service';
import { NotificationsService } from '@v3/app/services/notifications.service';
import { SharedService } from '@v3/app/services/shared.service';
import { BrowserStorageService } from '@v3/app/services/storage.service';
Expand Down Expand Up @@ -69,6 +70,7 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked {
private sharedService: SharedService,
private storageService: BrowserStorageService,
private unlockIndicatorService: UnlockIndicatorService,
private navigationStateService: NavigationStateService,
private cdr: ChangeDetectorRef,
private fastFeedbackService: FastFeedbackService,
private alertController: AlertController,
Expand Down Expand Up @@ -310,25 +312,35 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked {
}

if (this.unlockIndicatorService.isActivityClearable(activity.id)) {
const clearedActivityTodo = this.unlockIndicatorService.clearActivity(
activity.id
);
clearedActivityTodo?.forEach((todo) => {
this.notification
.markTodoItemAsDone(todo)
.pipe(first())
.subscribe(() => {
// eslint-disable-next-line no-console
console.log("Marked activity as done", todo);
});
});
// handles server-side duplicates and hierarchy
const currentTodoItems = this.notification.getCurrentTodoItems();
const result = this.unlockIndicatorService.clearByActivityIdWithDuplicates(activity.id, currentTodoItems);

// Handle marking duplicate TodoItems as done using centralized method
this.unlockIndicatorService.markDuplicatesAsDone(result, this.notification, 'activity');

// Fallback: if no duplicates found, try to clear inaccurate data
if (result.duplicatesToMark.length === 0 && result.clearedUnlocks.length === 0) {
const fallbackCleared = this.unlockIndicatorService.clearRelatedIndicators('activity', activity.id);
fallbackCleared?.forEach((todo) => {
this.notification
.markTodoItemAsDone(todo)
.pipe(first())
.subscribe(() => {
// eslint-disable-next-line no-console
console.log("Marked activity as done (fallback)", todo);
});
});
}
}

if (this.unlockIndicatorService.isMilestoneClearable(milestone.id)) {
this.verifyUnlockedMilestoneValidity(milestone.id);
}

if (!this.isMobile) {
// manually set navigation source
this.navigationStateService.setNavigationSource('home');
return this.router.navigate(["v3", "activity-desktop", activity.id]);
}

Expand All @@ -341,18 +353,26 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked {
* @return {void}
*/
verifyUnlockedMilestoneValidity(milestoneId: number): void {
// check & update unlocked milestones
const unlockedMilestones =
this.unlockIndicatorService.clearActivity(milestoneId);
unlockedMilestones.forEach((unlockedMilestone) => {
this.notification
.markTodoItemAsDone(unlockedMilestone)
.pipe(first())
.subscribe(() => {
// eslint-disable-next-line no-console
console.log("Marked milestone as done", unlockedMilestone);
});
});
// handles server-side duplicates clearing
const currentTodoItems = this.notification.getCurrentTodoItems();
const result = this.unlockIndicatorService.clearByMilestoneIdWithDuplicates(milestoneId, currentTodoItems);

// mark all duplicated TodoItems as done
this.unlockIndicatorService.markDuplicatesAsDone(result, this.notification, 'milestone');

// Fallback: if no duplicates found, try clearing for inaccurate unlock indicator todoItems
if (result.duplicatesToMark.length === 0) {
const fallbackCleared = this.unlockIndicatorService.clearRelatedIndicators('milestone', milestoneId);
fallbackCleared.forEach((unlockedMilestone) => {
this.notification
.markTodoItemAsDone(unlockedMilestone)
.pipe(first())
.subscribe(() => {
// eslint-disable-next-line no-console
console.log("Marked milestone as done (fallback)", unlockedMilestone);
});
});
}
}

async onTrackInfo() {
Expand Down
11 changes: 6 additions & 5 deletions projects/v3/src/app/services/fast-feedback.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,12 @@ export class FastFeedbackService {
closable?: boolean; // allow skipping modal popup (with a close button)
type?: string; // some pulsecheck require type: 'skills'
} = {
modalOnly: false,
skipChecking: false,
closable: false,
}): Observable<any> {
return this._getFastFeedback(options.skipChecking, options.type).pipe(
modalOnly: false,
skipChecking: false,
closable: false
}
): Observable<any> {
return this._getFastFeedback(options.skipChecking).pipe(
switchMap((res) => {
try {
// don't open it again if there's one opening
Expand Down
Loading