chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A] (#8892)

* chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A]

* update readme and remove-unused-log-imports.ts

* restore benchmark test and make runnable
This commit is contained in:
Lane Sawyer 2026-07-11 04:05:34 -07:00 committed by GitHub
parent 5220684b7e
commit c437dfc35a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 21 additions and 543 deletions

View file

@ -2365,7 +2365,7 @@ src/app/op-log/
│ ├── test-client.helper.ts # Test client utilities
│ └── operation-factory.helper.ts # Test operation builders
└── benchmarks/
└── operation-log-stress.spec.ts # Performance stress tests
└── operation-log-stress.benchmark.ts # Manual perf harness (not auto-run in CI)
src/app/features/work-context/store/
├── work-context-meta.actions.ts # Move actions (moveTaskInTodayList, etc.)

View file

@ -138,7 +138,10 @@ captured on the next tick.
- ⏳ **Remains: the on-device real-engine run.** sql.js validates the engine, not
the Capacitor bridge or the native plugin's specific SQLite build/flags. The
`operation-log-stress.benchmark.ts` harness is the lever for the on-device
perf + behavior pass (see B1 perf note).
perf + behavior pass (see B1 perf note). Run it explicitly with
`npm run test:file src/app/op-log/testing/benchmarks/operation-log-stress.benchmark.ts`;
it is compiled by `src/tsconfig.spec.json` but excluded from the auto-run
`**/*.spec.ts` set, so it never runs in normal CI.
### B3. Flip the DI token on native — init fix ✅ landed; token flip device-gated

View file

@ -83,7 +83,6 @@ e2e/
│ ├── settings.page.ts
│ ├── dialog.page.ts
│ ├── planner.page.ts
│ ├── schedule.page.ts
│ ├── side-nav.page.ts
│ ├── sync.page.ts
│ ├── tag.page.ts

View file

@ -1,2 +0,0 @@
// Schedule page removed - not used
export {};

View file

@ -1,16 +0,0 @@
import { ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
selector: 'base',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BaseComponent implements OnDestroy {
protected onDestroy$ = new Subject<void>();
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
}

View file

@ -1,2 +0,0 @@
export * from './clipboard-image.service';
export * from './resolve-clipboard-images.directive';

View file

@ -1,52 +0,0 @@
import { Pipe, PipeTransform, inject } from '@angular/core';
import { ClipboardImageService } from './clipboard-image.service';
import { Observable, from, of, defer } from 'rxjs';
import { map, shareReplay, catchError } from 'rxjs/operators';
import { Log } from '../log';
@Pipe({
name: 'resolveClipboardUrl',
standalone: true,
})
export class ResolveClipboardUrlPipe implements PipeTransform {
private readonly _clipboardImageService = inject(ClipboardImageService);
private readonly _cache = new Map<string, Observable<string>>();
transform(url: string | undefined | null): Observable<string> {
if (!url) {
return of('');
}
// If it's not an indexeddb URL, return as-is
if (!url.startsWith('indexeddb://clipboard-images/')) {
return of(url);
}
// Check cache first - if already resolving or resolved, return the cached observable
const cached = this._cache.get(url);
if (cached) {
return cached;
}
// Use defer to ensure the promise is created fresh and properly handled
const resolved$ = defer(() =>
from(this._clipboardImageService.resolveIndexedDbUrl(url)),
).pipe(
map((resolvedUrl) => {
if (resolvedUrl) {
return resolvedUrl;
}
return url;
}),
catchError((error) => {
Log.err('Error resolving clipboard URL:', error);
return of(url);
}),
shareReplay({ bufferSize: 1, refCount: false }),
);
// Cache immediately before returning so concurrent calls will get the same observable
this._cache.set(url, resolved$);
return resolved$;
}
}

View file

@ -1,20 +0,0 @@
import { createFeatureSelector, createReducer, on } from '@ngrx/store';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { ArchiveModel } from '../archive.model';
export const ARCHIVE_OLD_FEATURE_NAME = 'archiveOld';
export const initialArchiveOldState: ArchiveModel | null = null;
export const archiveOldReducer = createReducer<ArchiveModel | null>(
initialArchiveOldState,
on(
loadAllData,
(_state, { appDataComplete }) =>
(appDataComplete as { archiveOld?: ArchiveModel | null }).archiveOld ?? null,
),
);
export const selectArchiveOldFeatureState = createFeatureSelector<ArchiveModel | null>(
ARCHIVE_OLD_FEATURE_NAME,
);

View file

@ -1,20 +0,0 @@
import { createFeatureSelector, createReducer, on } from '@ngrx/store';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { ArchiveModel } from '../archive.model';
export const ARCHIVE_YOUNG_FEATURE_NAME = 'archiveYoung';
export const initialArchiveYoungState: ArchiveModel | null = null;
export const archiveYoungReducer = createReducer<ArchiveModel | null>(
initialArchiveYoungState,
on(
loadAllData,
(_state, { appDataComplete }) =>
(appDataComplete as { archiveYoung?: ArchiveModel | null }).archiveYoung ?? null,
),
);
export const selectArchiveYoungFeatureState = createFeatureSelector<ArchiveModel | null>(
ARCHIVE_YOUNG_FEATURE_NAME,
);

View file

@ -1,9 +0,0 @@
import { SearchResultItem } from '../issue/issue.model';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AddTaskPanel {
export interface IssueItem {
issueProviderId: string;
searchResultItem: SearchResultItem;
}
}

View file

@ -1,10 +0,0 @@
export const isValidProjectExport = (d: any): boolean => {
return !!(
d &&
d.id &&
d.title &&
d.relatedModels &&
d.advancedCfg &&
d.relatedModels.task
);
};

View file

@ -1,40 +0,0 @@
import { CapacitorHttp, HttpResponse } from '@capacitor/core';
import {
executeNativeRequestWithRetry as packageExecuteNativeRequestWithRetry,
isTransientNetworkError as packageIsTransientNetworkError,
type NativeHttpExecutor,
type NativeHttpRequestConfig,
type NativeHttpResponse,
} from '@sp/sync-providers/http';
import { OP_LOG_SYNC_LOGGER } from '../core/sync-logger.adapter';
export type NativeRequestConfig = NativeHttpRequestConfig;
/**
* App-side adapter: wires CapacitorHttp + OP_LOG_SYNC_LOGGER into the
* package-owned retry helper. Existing callers keep their positional API.
*
* The package version (with `{ executor, logger, label, delay }` options)
* is the canonical interface provider files that move into
* `@sp/sync-providers` should call it directly.
*/
export const executeNativeRequestWithRetry = async (
config: NativeRequestConfig,
label: string = 'NativeHttp',
requestFn: (opts: NativeRequestConfig) => Promise<HttpResponse> = (opts) =>
CapacitorHttp.request(opts),
delayFn?: (ms: number) => Promise<void>,
): Promise<HttpResponse> => {
const executor: NativeHttpExecutor = (cfg) =>
requestFn(cfg) as unknown as Promise<NativeHttpResponse>;
const response = await packageExecuteNativeRequestWithRetry(config, {
executor,
logger: OP_LOG_SYNC_LOGGER,
label,
delay: delayFn,
});
return response as unknown as HttpResponse;
};
export const isTransientNetworkError = packageIsTransientNetworkError;

View file

@ -6,28 +6,35 @@
*
* To run these benchmarks:
* 1. Run: npm run test:file src/app/op-log/testing/benchmarks/operation-log-stress.benchmark.ts
* 3. Watch the console output for timing information
* 2. Watch the console output for timing information
*
* Note: Results vary significantly based on:
* - Browser/test runner
* - System I/O performance
* - Concurrent processes
*
* These tests are excluded from regular test runs to avoid flaky CI failures
* due to timing dependencies on system load.
* These tests are excluded from regular test runs (the Karma builder only
* auto-runs `**\/*.spec.ts`) to avoid flaky CI failures from timing
* dependencies on system load. They are still part of the spec TS program
* (`src/tsconfig.spec.json` includes `**\/*.benchmark.ts`) so they compile and
* only run when named explicitly via `test:file`.
*/
import { TestBed } from '@angular/core/testing';
import { OperationLogStoreService } from '../../persistence/operation-log-store.service';
import { ActionType, Operation, OpType, EntityType } from '../../core/operation.types';
import { uuidv7 } from '../../../util/uuid-v7';
// Increase timeout for stress tests
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000;
// Stress-run timeout for the large-payload / 10k-op benchmarks. Must be set
// in `beforeEach` (not at module scope): `src/test.ts` has a global `beforeAll`
// that resets `DEFAULT_TIMEOUT_INTERVAL` to 2000ms, which would otherwise clobber
// a module-level assignment and time these benchmarks out.
const STRESS_TIMEOUT_MS = 60000;
describe('OperationLog Performance Benchmarks', () => {
let service: OperationLogStoreService;
beforeEach(async () => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = STRESS_TIMEOUT_MS;
TestBed.configureTestingModule({
providers: [OperationLogStoreService],
});

View file

@ -1,12 +0,0 @@
export const fixNumberField = (value: unknown, defaultValue?: number): number => {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const parsedValue = parseFloat(value);
if (!isNaN(parsedValue)) {
return parsedValue;
}
}
return typeof defaultValue === 'number' ? defaultValue : 0;
};

View file

@ -1,45 +0,0 @@
// import { GlobalConfigState } from '../../features/config/global-config.model';
// import { fixNumberField } from './fix-number-field';
// import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
//
// export const repairGlobalConfig = (
// globalConfig: GlobalConfigState,
// ): GlobalConfigState => {
// // Helper function to fix a number field at a specific path
// const fixNumberFieldInConfig = (obj: any, path: string[], defaultValue: number) => {
// const lastKey = path[path.length - 1];
// const parentObj =
// path.length > 1 ? path.slice(0, -1).reduce((o, k) => o[k], obj) : obj;
//
// if (typeof parentObj[lastKey] !== 'number') {
// parentObj[lastKey] = fixNumberField(parentObj[lastKey], defaultValue);
// }
// };
//
// // Fields to check with their paths and default values
// const fieldsToFix = [
// {
// path: ['misc', 'firstDayOfWeek'],
// defaultValue: DEFAULT_GLOBAL_CONFIG.misc.firstDayOfWeek,
// },
// {
// path: ['misc', 'startOfNextDay'],
// defaultValue: DEFAULT_GLOBAL_CONFIG.misc.startOfNextDay,
// },
// {
// path: ['pomodoro', 'breakDuration'],
// defaultValue: DEFAULT_GLOBAL_CONFIG.pomodoro.breakDuration,
// },
// {
// path: ['pomodoro', 'cyclesBeforeLongerBreak'],
// defaultValue: DEFAULT_GLOBAL_CONFIG.pomodoro.cyclesBeforeLongerBreak,
// },
// ];
//
// // Apply fixes to all fields
// fieldsToFix.forEach(({ path, defaultValue }) => {
// fixNumberFieldInConfig(globalConfig, path, defaultValue);
// });
//
// return globalConfig as GlobalConfigState;
// };

View file

@ -1,4 +0,0 @@
import { ActionReducerMap } from '@ngrx/store';
import { RootState } from './root-state';
export const reducers: Partial<ActionReducerMap<RootState>> = {};

View file

@ -1,13 +0,0 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { ANI_STANDARD_TIMING } from './animation.const';
export const dynamicHeightAnimation = [
trigger('dynamicHeight', [
transition('void <=> *', []),
transition(
'* <=> *',
[style({ height: '{{startHeight}}px', opacity: 0 }), animate(ANI_STANDARD_TIMING)],
{ params: { startHeight: 0 } },
),
]),
];

View file

@ -1,4 +0,0 @@
import { transition, trigger } from '@angular/animations';
// use on parent to skip initial animation
export const noopAnimation = trigger('noop', [transition(':enter', [])]);

View file

@ -1,16 +0,0 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { ANI_ENTER_TIMING } from './animation.const';
export const slideInFromLeftAni = [
trigger('slideInFromLeft', [
transition(':enter', [
style({ transform: 'translateX(-100%)' }),
animate(ANI_ENTER_TIMING, style({ transform: 'translateX(0)' })),
]),
// transition(':leave', [
// style({ transform: 'translateX(0)' }),
// animate(ANI_ENTER_TIMING, style({ transform: 'translateX(-100%)' })),
// ]),
]),
];

View file

@ -1,28 +0,0 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { ANI_ENTER_TIMING, ANI_LEAVE_TIMING } from './animation.const';
export const slideAnimation = [
trigger('slide', [
transition(':enter', [
style({ marginTop: '-{{elHeight}}px', opacity: 0 }),
animate(ANI_ENTER_TIMING, style({ marginTop: '*', opacity: 1 })),
]), // void => *
transition(':leave', [
style({ marginTop: 0, opacity: 1 }),
animate(ANI_LEAVE_TIMING, style({ marginTop: '-{{elHeight}}px', opacity: 0 })),
]),
]),
];
// export const slideAnimation = [
// trigger('slide', [
// transition(':enter', [
// style({height: 0, overflow: 'hidden', transform: 'translateY(-120%)'}),
// animate(ANI_ENTER_TIMING, style({height: '*', transform: 'translateY(0)'}))
// ]), // void => *
// transition(':leave', [
// style({overflow: 'hidden'}),
// animate(ANI_LEAVE_TIMING, style({height: 0, transform: 'translateY(-120%)'}))
// ])
// ])
// ];

View file

@ -1,42 +0,0 @@
import { filter, OperatorFunction } from 'rxjs';
import { Action } from '@ngrx/store';
/**
* RxJS operator that filters out remote actions from sync.
*
* Use this in effects that should only run for local user actions,
* not for actions replayed from remote sync.
*
* @example
* ```typescript
* myEffect$ = createEffect(() =>
* this._actions$.pipe(
* ofType(TaskSharedActions.addTask),
* filterLocalAction(),
* // ... rest of effect
* )
* );
* ```
*/
export const filterLocalAction = <T extends Action>(): OperatorFunction<T, T> =>
filter((action: T) => !(action as any).meta?.isRemote);
/**
* RxJS operator that filters to only remote actions from sync.
*
* Use this in effects that should only run for remote sync actions,
* not for local user actions.
*
* @example
* ```typescript
* myEffect$ = createEffect(() =>
* this._actions$.pipe(
* ofType(TaskSharedActions.moveToArchive),
* filterRemoteAction(),
* // ... rest of effect
* )
* );
* ```
*/
export const filterRemoteAction = <T extends Action>(): OperatorFunction<T, T> =>
filter((action: T) => !!(action as any).meta?.isRemote);

View file

@ -1,34 +0,0 @@
export const formatISODateWithOffset = (date: Date): string => {
const pad = (num, length = 2): string => String(num).padStart(length, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1); // Months are 0-indexed
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
const milliseconds = pad(date.getMilliseconds(), 3); // Standard 3 digits
// --- Timezone Offset Calculation ---
const offsetMinutes = date.getTimezoneOffset();
const offsetHours = Math.abs(Math.floor(offsetMinutes / 60));
const offsetMinPart = Math.abs(offsetMinutes % 60);
// getTimezoneOffset returns POSITIVE for zones WEST of UTC, and NEGATIVE for EAST.
// The standard ISO format requires the opposite sign (+ for EAST, - for WEST).
const offsetSign = offsetMinutes <= 0 ? '+' : '-';
const offsetFormatted = `${offsetSign}${pad(offsetHours)}:${pad(offsetMinPart)}`;
// Special case for UTC (offset is 0) - some prefer 'Z'
// const offsetFormatted = offsetMinutes === 0 ? 'Z' : `${offsetSign}${pad(offsetHours)}:${pad(offsetMinPart)}`;
// --- Assemble the String ---
// Using standard 3-digit milliseconds (.SSS)
const formattedString = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}${offsetFormatted}`;
/*
// If you strictly need 2-digit milliseconds (.SS), uncomment this:
const millisecondsTwoDigits = milliseconds.substring(0, 2);
formattedString = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${millisecondsTwoDigits}${offsetFormatted}`;
*/
return formattedString;
};

View file

@ -1,37 +0,0 @@
// borrowed
export const getTextFromArrayBuffer = (
arrayBuffer: ArrayBuffer,
encoding: string = 'UTF-8',
): Promise<string | ArrayBuffer> => {
return new Promise((resolve /*, reject*/) => {
if (typeof Blob === 'undefined') {
const buffer = new Buffer(new Uint8Array(arrayBuffer));
resolve(buffer.toString(encoding));
} else {
let blob;
const gc = window as any;
// TODO fix as BlobBuilder is not available in all browsers
// @see https://developer.mozilla.org/en-US/docs/Web/API/BlobBuilder
gc.BlobBuilder = gc.BlobBuilder || gc.WebKitBlobBuilder;
if (typeof gc.BlobBuilder !== 'undefined') {
const bb = new gc.BlobBuilder();
bb.append(arrayBuffer);
blob = bb.getBlob();
} else {
blob = new Blob([arrayBuffer]);
}
const fileReader = new FileReader();
if (typeof fileReader.addEventListener === 'function') {
fileReader.addEventListener('loadend', (evt) => {
resolve(evt.target.result);
});
} else {
fileReader.onloadend = (evt) => {
resolve(evt.target.result);
};
}
fileReader.readAsText(blob, encoding);
}
});
};

View file

@ -1,6 +0,0 @@
export const getYesterdaysDate = (): Date => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
return yesterday;
};

View file

@ -1,21 +0,0 @@
// TAKEN from https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
// NOTE: NEVER CHANGE THIS AS IT IS USED FOR ID GENERATION
const SEED = 454;
const cyrb53a = (str: string): number => {
let h1 = 0xdeadbeef ^ SEED,
h2 = 0x41c6ce57 ^ SEED;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 0x85ebca77);
h2 = Math.imul(h2 ^ ch, 0xc2b2ae3d);
}
h1 ^= Math.imul(h1 ^ (h2 >>> 15), 0x735a2d97);
h2 ^= Math.imul(h2 ^ (h1 >>> 15), 0xcaf649a9);
h1 ^= h2 >>> 16;
h2 ^= h1 >>> 16;
// eslint-disable-next-line no-mixed-operators
return 2097152 * (h2 >>> 0) + (h1 >>> 11);
};
// NOTE: NEVER CHANGE THIS FN AS IT IS USED FOR ID GENERATION
export const hash = (str: string): string => cyrb53a(str).toString();

View file

@ -1 +0,0 @@
export const IS_CHROME = navigator.userAgent.toLowerCase().indexOf('chrome') > -1;

View file

@ -1,3 +0,0 @@
const emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
export const isEmail = (str: string): boolean =>
typeof str === 'string' && !!str.match(emailRegex);

View file

@ -1,42 +0,0 @@
import { defer, Observable, Subscriber } from 'rxjs';
import { finalize, tap } from 'rxjs/operators';
import { Log } from '../core/log';
export const obsLogAll$ = <T>(
sourceOrName: Observable<T> | string,
source?: Observable<T>,
): Observable<T> => {
const name = typeof sourceOrName === 'string' ? sourceOrName : 'obs$';
const usedSource = typeof sourceOrName === 'string' ? source : sourceOrName;
return subscriberCount$(obsLog$(usedSource, name), name);
};
export const obsLog$ = <T>(source: Observable<T>, name: string = 'obs$'): Observable<T> =>
defer(() => {
Log.log(`_o_ ${name}: subscribed ++`);
return source.pipe(
tap({
next: (value) => Log.log(`_o_ ${name}: ${value}`),
complete: () => Log.log(`_o_ ${name}: $$$ complete $$$`),
}),
finalize(() => Log.log(`_o_ ${name}: unsubscribed --`)),
);
});
export const subscriberCount$ = <T>(
sourceObservable: Observable<T>,
name: string = 'obs$',
): Observable<T> => {
let counter = 0;
return new Observable((subscriber: Subscriber<T>) => {
const subscription = sourceObservable.subscribe(subscriber);
counter++;
Log.log(`_o_ ${name} subs: ${counter}`);
return () => {
subscription.unsubscribe();
counter--;
Log.log(`_o_ ${name} subs: ${counter}`);
};
});
};

View file

@ -1,26 +0,0 @@
import { BehaviorSubject, Observable } from 'rxjs';
export const observeObjectProperty = <T extends object, K extends keyof T>(
obj: T,
propertyKey: K,
): Observable<T[K]> => {
// Create a subject with the initial value
const subject = new BehaviorSubject<T[K]>(obj[propertyKey]);
// Store the original value
let value = obj[propertyKey];
// Define a getter/setter for the property
Object.defineProperty(obj, propertyKey, {
configurable: true,
enumerable: true,
get: () => value,
set: (newValue) => {
value = newValue;
subject.next(newValue);
},
});
// Return the observable part of the subject
return subject.asObservable();
};

View file

@ -1,21 +0,0 @@
import { Observable, Subscriber } from 'rxjs';
import { Log } from '../core/log';
export const observeWidth = (target: HTMLElement): Observable<number> => {
return new Observable((observer: Subscriber<number>) => {
if ((window as any).ResizeObserver) {
const resizeObserver = new (window as any).ResizeObserver(
(entries: ResizeObserverEntry[]) => {
observer.next(entries[0].contentRect.width);
},
);
resizeObserver.observe(target);
return () => {
resizeObserver.unobserve(target);
};
} else {
Log.err('ResizeObserver not supported in this browser');
return undefined;
}
});
};

View file

@ -1,8 +0,0 @@
export const timestampToDatetimeInputString = (timestamp: number): string => {
const date = new Date(timestamp + _getTimeZoneOffsetInMs());
return date.toISOString().slice(0, 19);
};
const _getTimeZoneOffsetInMs = (): number => {
return new Date().getTimezoneOffset() * -60 * 1000;
};

View file

@ -29,5 +29,8 @@
}
},
"files": ["test.ts", "polyfills.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"]
// `**/*.benchmark.ts` files are part of the spec TS program so they compile
// (and are type-checked in CI), but they are NOT auto-run: the Karma builder's
// default `include` is `**/*.spec.ts`, so benchmarks need to be run manually.
"include": ["**/*.spec.ts", "**/*.benchmark.ts", "**/*.d.ts"]
}