diff --git a/src/app/analyzer/chart-view/chart-view.css b/src/app/analyzer/chart-view/chart-view.css index 28839cb..82f01a7 100644 --- a/src/app/analyzer/chart-view/chart-view.css +++ b/src/app/analyzer/chart-view/chart-view.css @@ -21,6 +21,27 @@ text-transform: capitalize; } +.chart-tag-remove { + background: none; + border: none; + padding: 0 0 0 3px; + margin: 0; + color: inherit; + opacity: 0.5; + font-size: 0.9em; + cursor: pointer; +} + +.chart-tag-remove:hover { + opacity: 1; +} + +.chart-tag-editor { + display: block; + margin-top: 4px; + max-width: 260px; +} + .chm-highlight-range { font-size: 0.9em; margin: 0; diff --git a/src/app/analyzer/chart-view/chart-view.html b/src/app/analyzer/chart-view/chart-view.html index 32254f2..71bb9e3 100644 --- a/src/app/analyzer/chart-view/chart-view.html +++ b/src/app/analyzer/chart-view/chart-view.html @@ -90,9 +90,26 @@

No Telemetry Loaded

@if ((file.tags ?? []).length > 0) {
@for (tag of file.tags; track tag) { - {{ tag }} + + {{ tag }} + + }
+ } @if (tagEditorIndex() === idx) { + }
@@ -133,7 +150,7 @@

No Telemetry Loaded

> - } - + Tag Get Link
+ @if (tagEditorFileId() === item.file.id) { + + } } diff --git a/src/app/analyzer/drive-panel/drive-panel.ts b/src/app/analyzer/drive-panel/drive-panel.ts index f9cd95e..8765334 100644 --- a/src/app/analyzer/drive-panel/drive-panel.ts +++ b/src/app/analyzer/drive-panel/drive-panel.ts @@ -2,6 +2,8 @@ import { Component, inject, signal } from '@angular/core'; import { AccountService } from '../../core/account.service'; import { AuthService } from '../../core/auth.service'; import { DriveFileEntry, DriveService } from '../../core/drive.service'; +import { tagStyle } from '../../core/tags.util'; +import { TagInput } from '../tag-input/tag-input'; /** * Cloud Files section of the sidebar. Ports the sign-in/list/load path, @@ -10,7 +12,7 @@ import { DriveFileEntry, DriveService } from '../../core/drive.service'; */ @Component({ selector: 'app-drive-panel', - imports: [], + imports: [TagInput], templateUrl: './drive-panel.html', styleUrl: './drive-panel.css', }) @@ -22,6 +24,10 @@ export class DrivePanel { protected readonly showClientIdInput = signal(false); protected readonly clientIdDraft = signal(''); protected readonly recentExpanded = signal(false); + /** Drive file id whose inline tag editor is open, or null — only one at a time, like the prompt it replaces. */ + protected readonly tagEditorFileId = signal(null); + + protected readonly tagStyle = tagStyle; protected connect(): void { void this.drive.connectAndScan(); @@ -70,20 +76,23 @@ export class DrivePanel { void this.drive.loadFile(entry.file.name, entry.file.id); } - protected addTag(entry: DriveFileEntry, event: Event): void { + protected openTagEditor(entry: DriveFileEntry, event: Event): void { + event.stopPropagation(); + this.tagEditorFileId.set(entry.file.id); + } + + protected closeTagEditor(): void { + this.tagEditorFileId.set(null); + } + + protected submitTag(entry: DriveFileEntry, tag: string): void { + this.tagEditorFileId.set(null); + void this.drive.addTag(entry, tag); + } + + protected removeTag(entry: DriveFileEntry, tag: string, event: Event): void { event.stopPropagation(); - const tag = window.prompt('Enter a new tag (e.g., Track, Commute, Rain):'); - if (tag) void this.drive.addTag(entry, tag); - } - - /** Port of legacy/src/drive.js's `_getTagStyle` — deterministic hue per tag name. */ - protected tagStyle(tag: string): string { - let hash = 0; - for (let i = 0; i < tag.length; i++) { - hash = tag.charCodeAt(i) + ((hash << 5) - hash); - } - const hue = Math.abs(hash) % 360; - return `background: hsla(${hue}, 70%, 50%, 0.15); color: var(--text-primary); border: 1px solid hsla(${hue}, 70%, 50%, 0.3);`; + void this.drive.removeTag(entry, tag); } protected filterByTag(tag: string, event: Event): void { diff --git a/src/app/analyzer/tag-input/tag-input.css b/src/app/analyzer/tag-input/tag-input.css new file mode 100644 index 0000000..6b7613b --- /dev/null +++ b/src/app/analyzer/tag-input/tag-input.css @@ -0,0 +1,52 @@ +.tag-input { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.tag-input-field { + flex: 1 1 120px; + min-width: 90px; + background: var(--surface-1); + border: 1px solid var(--border); + color: var(--text-primary); + border-radius: 12px; + padding: 2px 8px; + font-size: 0.7em; + font-family: inherit; +} + +.tag-input-field:focus { + outline: none; + border-color: var(--accent); +} + +.tag-input-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + border-radius: 12px; + padding: 2px 7px; + font-size: 0.7em; + line-height: 1.4; + cursor: pointer; + transition: + color 0.2s, + border-color 0.2s; +} + +.tag-input-btn:hover { + color: var(--text-primary); +} + +.tag-input-btn.confirm:hover { + color: var(--accent); + border-color: var(--accent); +} + +.tag-input-preview { + flex-basis: 100%; + color: var(--text-secondary); + font-size: 0.65em; +} diff --git a/src/app/analyzer/tag-input/tag-input.html b/src/app/analyzer/tag-input/tag-input.html new file mode 100644 index 0000000..1f8ee88 --- /dev/null +++ b/src/app/analyzer/tag-input/tag-input.html @@ -0,0 +1,44 @@ +
+ + + @for (suggestion of suggestions(); track suggestion) { + + } + + + + @if (preview() && preview() !== draft()) { + + saves as {{ preview() }} + + } +
diff --git a/src/app/analyzer/tag-input/tag-input.spec.ts b/src/app/analyzer/tag-input/tag-input.spec.ts new file mode 100644 index 0000000..4394881 --- /dev/null +++ b/src/app/analyzer/tag-input/tag-input.spec.ts @@ -0,0 +1,126 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { TagInput } from './tag-input'; + +function makeFixture(suggestions: string[] = []) { + const fixture = TestBed.createComponent(TagInput); + fixture.componentRef.setInput('suggestions', suggestions); + fixture.detectChanges(); + return fixture; +} + +function field(fixture: { nativeElement: HTMLElement }): HTMLInputElement { + return fixture.nativeElement.querySelector( + '.tag-input-field' + ) as HTMLInputElement; +} + +function type(fixture: { nativeElement: HTMLElement }, value: string): void { + const input = field(fixture); + input.value = value; + input.dispatchEvent(new Event('input')); +} + +describe('TagInput', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TagInput], + }).compileComponents(); + }); + + it('offers every suggestion in its datalist', () => { + const fixture = makeFixture(['175tbi', 'track']); + + const options = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('datalist option') + ).map((o) => (o as HTMLOptionElement).value); + + expect(options).toEqual(['175tbi', 'track']); + }); + + it('links the field to its own datalist so two instances do not share one', () => { + const first = makeFixture(); + const second = makeFixture(); + + expect(field(first).getAttribute('list')).not.toBe( + field(second).getAttribute('list') + ); + }); + + it('emits the typed tag on Enter', () => { + const fixture = makeFixture(); + const submitted: string[] = []; + fixture.componentInstance.submitted.subscribe((v: string) => + submitted.push(v) + ); + + type(fixture, ' Track Day '); + field(fixture).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter' }) + ); + + expect(submitted).toEqual(['Track Day']); + }); + + it('cancels instead of submitting when the field is empty', () => { + const fixture = makeFixture(); + const submitted: string[] = []; + let cancelled = 0; + fixture.componentInstance.submitted.subscribe((v: string) => + submitted.push(v) + ); + fixture.componentInstance.cancelled.subscribe(() => cancelled++); + + type(fixture, ' '); + field(fixture).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter' }) + ); + + expect(submitted).toEqual([]); + expect(cancelled).toBe(1); + }); + + it('cancels on Escape', () => { + const fixture = makeFixture(); + let cancelled = 0; + fixture.componentInstance.cancelled.subscribe(() => cancelled++); + + type(fixture, 'track'); + field(fixture).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape' }) + ); + + expect(cancelled).toBe(1); + }); + + it('previews the stored spelling only when it differs from what was typed', () => { + const fixture = makeFixture(); + const preview = () => + (fixture.nativeElement as HTMLElement).querySelector( + '.tag-input-preview' + ); + + type(fixture, 'track'); + fixture.detectChanges(); + expect(preview()).toBeFalsy(); + + type(fixture, 'Track,Rain'); + fixture.detectChanges(); + expect(preview()?.textContent).toContain('track rain'); + }); + + it('keeps clicks inside it from reaching the card it is rendered in', () => { + const fixture = makeFixture(); + const host = (fixture.nativeElement as HTMLElement).querySelector( + '.tag-input' + ) as HTMLElement; + const card = document.createElement('div'); + let cardClicks = 0; + card.addEventListener('click', () => cardClicks++); + card.appendChild(fixture.nativeElement as HTMLElement); + + host.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(cardClicks).toBe(0); + }); +}); diff --git a/src/app/analyzer/tag-input/tag-input.ts b/src/app/analyzer/tag-input/tag-input.ts new file mode 100644 index 0000000..4284acf --- /dev/null +++ b/src/app/analyzer/tag-input/tag-input.ts @@ -0,0 +1,73 @@ +import { + Component, + ElementRef, + afterNextRender, + computed, + input, + output, + signal, + viewChild, +} from '@angular/core'; +import { normalizeTag } from '../../core/tags.util'; + +/** Each instance needs its own `` id to link to; several can be mounted at once (one per chart card / Drive file card). */ +let nextListId = 0; + +/** + * The inline tag editor used by both the chart cards and the Drive panel, + * replacing the `window.prompt` both used to call. A prompt could not offer + * the tags already in use, which is what let near-misses ('2.0 gme') get + * typed in the first place — and a mistyped vehicle tag silently changes + * which channel sets a log gets (see core/vehicle-tags.ts). + * + * Suggestions are offered through a native `` rather than a custom + * popup: it filters as you type, stays keyboard-navigable for free, and does + * not constrain the input, so a brand-new tag is still just typed. + */ +@Component({ + selector: 'app-tag-input', + imports: [], + templateUrl: './tag-input.html', + styleUrl: './tag-input.css', +}) +export class TagInput { + /** Tags to offer while typing — see DriveService.knownTags. */ + readonly suggestions = input([]); + readonly placeholder = input('Add a tag'); + + /** The raw text typed; the receiving service canonicalizes it (AppStateService.addFileTag / DriveService.addTag). */ + readonly submitted = output(); + readonly cancelled = output(); + + protected readonly listId = `tag-suggestions-${nextListId++}`; + protected readonly draft = signal(''); + + private readonly inputEl = + viewChild>('tagField'); + + constructor() { + afterNextRender(() => this.inputEl()?.nativeElement.focus()); + } + + /** Shown under the field as a preview, so the normalization (lowercasing, a comma turning into a space) is visible before it is applied rather than surprising afterwards. */ + protected readonly preview = computed(() => normalizeTag(this.draft())); + + protected onInput(event: Event): void { + this.draft.set((event.target as HTMLInputElement).value); + } + + protected submit(): void { + const value = this.draft().trim(); + if (!value) { + this.cancelled.emit(); + return; + } + this.submitted.emit(value); + this.draft.set(''); + } + + protected cancel(): void { + this.draft.set(''); + this.cancelled.emit(); + } +} diff --git a/src/app/core/acceleration.service.ts b/src/app/core/acceleration.service.ts index edb9991..fc70371 100644 --- a/src/app/core/acceleration.service.ts +++ b/src/app/core/acceleration.service.ts @@ -3,6 +3,7 @@ import { AccountService } from './account.service'; import { AppStateService } from './app-state.service'; import { DbManagerService } from './db-manager.service'; import { LoadedFile, SignalPoint } from './models'; +import { VEHICLE_TAG_20GME, fileHasVehicleTag } from './vehicle-tags'; /** Matches DriveService's DRIVE_FEATURE_NAME convention -- a mygiulia-backend feature name, resolved via AccountService.hasFeature(). */ const ACCELERATION_FEATURE_NAME = 'acceleration-runs'; @@ -11,14 +12,14 @@ const ACCELERATION_FEATURE_NAME = 'acceleration-runs'; const TEMP_SIGNAL_PATTERN = /temp/i; /** - * Files tagged '2.0gme' (LoadedFile.tags, seeded from the Drive appProperties - * this file was loaded with -- see DriveService.loadFile) record these extra - * channels alongside vehicle speed during a 0-100 pull. Captured as full - * curves (not just a launch reading, unlike TEMP_SIGNAL_PATTERN) so the + * Files tagged VEHICLE_TAG_20GME (LoadedFile.tags, seeded from the Drive + * appProperties this file was loaded with -- see DriveService.loadFile, and + * vehicle-tags.ts for the registry of tags the app branches on) record these + * extra channels alongside vehicle speed during a 0-100 pull. Captured as + * full curves (not just a launch reading, unlike TEMP_SIGNAL_PATTERN) so the * Registry can chart them alongside speed for a saved run, same as the live * "Overlay Signals" picker does for the currently-loaded file. */ -const TWO_ZERO_GME_TAG = '2.0gme'; const TWO_ZERO_GME_EXTRA_SIGNALS = [ 'Engine Speed', 'Vehicle Speed', @@ -548,7 +549,7 @@ export class AccelerationService { launchTime: number, targetTime: number ): ExtraCurve[] { - if (!(file.tags ?? []).includes(TWO_ZERO_GME_TAG)) return []; + if (!fileHasVehicleTag(file, VEHICLE_TAG_20GME)) return []; // Matched case/whitespace-insensitively against the file's actual signal // names rather than requiring an exact object-key hit -- the same diff --git a/src/app/core/app-state.service.spec.ts b/src/app/core/app-state.service.spec.ts index ae7584d..3dc48d9 100644 --- a/src/app/core/app-state.service.spec.ts +++ b/src/app/core/app-state.service.spec.ts @@ -253,6 +253,131 @@ describe('AppStateService', () => { expect(db.updateFileTags).not.toHaveBeenCalled(); }); + it('addFileTag canonicalizes what it stores', () => { + const state = new AppStateService(new EventBusService(), makeDbFake()); + state.addFile(makeFile({ dbId: 1 })); + + state.addFileTag(0, ' Track Day '); + state.addFileTag(1, 'x'); + state.addFileTag(0, '2.0 GME'); + + expect(state.files()[0].tags).toEqual(['track day', '2.0gme']); + }); + + it('addFileTag rejects a tag that normalizes to nothing', () => { + const state = new AppStateService(new EventBusService(), makeDbFake()); + state.addFile(makeFile({ dbId: 1 })); + + expect(state.addFileTag(0, ' , ')).toBe(false); + expect(state.files()[0].tags).toBeUndefined(); + }); + + it('removeFileTag drops the tag, persists it, and emits FILE_TAG_REMOVED', () => { + const bus = new EventBusService(); + const db = makeDbFake(); + const state = new AppStateService(bus, db); + state.addFile( + makeFile({ dbId: 7, name: 'a.json', tags: ['track', 'rain'] }) + ); + + const received: unknown[] = []; + bus.on('file:tag-removed').subscribe((event) => received.push(event)); + + const removed = state.removeFileTag(0, 'track'); + + expect(removed).toBe(true); + expect(state.files()[0].tags).toEqual(['rain']); + expect(db.updateFileTags).toHaveBeenCalledWith(7, ['rain']); + expect(received).toEqual([{ fileName: 'a.json', tag: 'track' }]); + }); + + it('removeFileTag matches the stored spelling exactly rather than canonicalizing', () => { + const state = new AppStateService(new EventBusService(), makeDbFake()); + state.addFile(makeFile({ dbId: 1, tags: ['2.0 GME'] })); + + expect(state.removeFileTag(0, '2.0gme')).toBe(false); + expect(state.removeFileTag(0, '2.0 GME')).toBe(true); + expect(state.files()[0].tags).toEqual([]); + }); + + it('removeFileTag returns false for a tag the file does not carry', () => { + const db = makeDbFake(); + const state = new AppStateService(new EventBusService(), db); + state.addFile(makeFile({ dbId: 1, tags: ['track'] })); + + expect(state.removeFileTag(0, 'rain')).toBe(false); + expect(db.updateFileTags).not.toHaveBeenCalled(); + }); + + it('applies a DRIVE_TAG_REMOVED event to the matching loaded file', () => { + const bus = new EventBusService(); + const db = makeDbFake(); + const state = new AppStateService(bus, db); + state.addFile( + makeFile({ dbId: 7, name: 'a.json', tags: ['track', 'rain'] }) + ); + + const echoed: unknown[] = []; + bus.on('file:tag-removed').subscribe((event) => echoed.push(event)); + + bus.emit('drive:tag-removed', { fileName: 'a.json', tag: 'rain' }); + + expect(state.files()[0].tags).toEqual(['track']); + expect(db.updateFileTags).toHaveBeenCalledWith(7, ['track']); + expect(echoed).toEqual([]); + }); + + it('applies a DRIVE_TAG_ADDED event to the matching loaded file and its DB record', () => { + const bus = new EventBusService(); + const db = makeDbFake(); + const state = new AppStateService(bus, db); + state.addFile(makeFile({ dbId: 7, name: 'a.json' })); + state.addFile(makeFile({ dbId: 8, name: 'b.json' })); + + bus.emit('drive:tag-added', { fileName: 'a.json', tag: '2.0gme' }); + + expect(state.files()[0].tags).toEqual(['2.0gme']); + expect(state.files()[1].tags).toBeUndefined(); + expect(db.updateFileTags).toHaveBeenCalledWith(7, ['2.0gme']); + }); + + it('does not echo a DRIVE_TAG_ADDED event back out as FILE_TAG_ADDED', () => { + const bus = new EventBusService(); + const state = new AppStateService(bus, makeDbFake()); + state.addFile(makeFile({ dbId: 1, name: 'a.json' })); + + const received: unknown[] = []; + bus.on('file:tag-added').subscribe((event) => received.push(event)); + + bus.emit('drive:tag-added', { fileName: 'a.json', tag: 'track' }); + + expect(received).toEqual([]); + }); + + it('ignores a DRIVE_TAG_ADDED event for a file that is not loaded', () => { + const bus = new EventBusService(); + const db = makeDbFake(); + const state = new AppStateService(bus, db); + state.addFile(makeFile({ dbId: 1, name: 'a.json' })); + + bus.emit('drive:tag-added', { fileName: 'elsewhere.json', tag: 'track' }); + + expect(state.files()[0].tags).toBeUndefined(); + expect(db.updateFileTags).not.toHaveBeenCalled(); + }); + + it('leaves a loaded file alone when DRIVE_TAG_ADDED repeats a tag it already carries', () => { + const bus = new EventBusService(); + const db = makeDbFake(); + const state = new AppStateService(bus, db); + state.addFile(makeFile({ dbId: 1, name: 'a.json', tags: ['track'] })); + + bus.emit('drive:tag-added', { fileName: 'a.json', tag: 'track' }); + + expect(state.files()[0].tags).toEqual(['track']); + expect(db.updateFileTags).not.toHaveBeenCalled(); + }); + it('showAlert/clearAlert set and clear the alert message', () => { const state = new AppStateService(new EventBusService(), makeDbFake()); expect(state.alertMessage()).toBeNull(); diff --git a/src/app/core/app-state.service.ts b/src/app/core/app-state.service.ts index 833bbf2..a492589 100644 --- a/src/app/core/app-state.service.ts +++ b/src/app/core/app-state.service.ts @@ -13,6 +13,7 @@ import { SignalPoint, ViewMode, } from './models'; +import { canonicalTag } from './vehicle-tags'; /** * How long a toast stays up before it dismisses itself, per severity. @@ -51,7 +52,16 @@ export class AppStateService { constructor( private readonly bus: EventBusService, private readonly db: DbManagerService - ) {} + ) { + this.bus + .on(EVENTS.DRIVE_TAG_ADDED) + .subscribe(({ fileName, tag }) => this.applyTagFromDrive(fileName, tag)); + this.bus + .on(EVENTS.DRIVE_TAG_REMOVED) + .subscribe(({ fileName, tag }) => + this.applyTagRemovalFromDrive(fileName, tag) + ); + } /** Shows a self-dismissing toast; `severity` picks its color and lifetime. */ showAlert(message: string, severity: AlertSeverity = 'info'): void { @@ -170,17 +180,18 @@ export class AppStateService { * Port of legacy/src/chartmanager.js's `_promptForTag`. Returns false * (without mutating state) if the file already has this tag, matching * legacy's "already applied" alert path — the caller shows that alert. + * + * `rawTag` is canonicalized here rather than by each caller, so anything + * that tags a file gets the same normalization (and the same snap onto a + * registered vehicle tag) without having to remember to ask for it. */ - addFileTag(fileIndex: number, tag: string): boolean { + addFileTag(fileIndex: number, rawTag: string): boolean { const file = this.files()[fileIndex]; if (!file) return false; - if ((file.tags ?? []).includes(tag)) return false; + const tag = canonicalTag(rawTag); + if (!tag) return false; + if (!this.applyTagAt(fileIndex, tag)) return false; - const updatedTags = [...(file.tags ?? []), tag]; - this.files.update((files) => - files.map((f, i) => (i !== fileIndex ? f : { ...f, tags: updatedTags })) - ); - if (file.dbId !== null) void this.db.updateFileTags(file.dbId, updatedTags); this.bus.emit(EVENTS.FILE_TAG_ADDED, { fileName: file.name, tag, @@ -188,6 +199,83 @@ export class AppStateService { return true; } + /** + * Drops `tag` from the file at `fileIndex`, announcing FILE_TAG_REMOVED so + * DriveService can drop it from the file's Drive appProperties too. + * + * Matched exactly, unlike `addFileTag`: a tag seeded from Drive was never + * normalized on the way in, so canonicalizing here would miss it. + */ + removeFileTag(fileIndex: number, tag: string): boolean { + const file = this.files()[fileIndex]; + if (!file) return false; + if (!this.dropTagAt(fileIndex, tag)) return false; + + this.bus.emit(EVENTS.FILE_TAG_REMOVED, { + fileName: file.name, + tag, + }); + return true; + } + + /** + * The Drive-panel half of the tag sync (DriveService.addTag emits + * DRIVE_TAG_ADDED once the appProperties write lands): tagging a log that + * is also currently loaded has to reach `files` too, or the loaded copy + * keeps behaving as untagged -- a '2.0gme' tag added from the panel would + * silently not apply to the WOT filter's channel set or the acceleration + * Registry's extra curves until the file was reloaded. + * + * Matched by name, like DriveService's own syncTagFromChart, and applied + * to every match since nothing stops two loaded files sharing a name. + * Deliberately does not emit FILE_TAG_ADDED -- that would bounce straight + * back into DriveService.addTag. + */ + private applyTagFromDrive(fileName: string, tag: string): void { + this.files().forEach((file, index) => { + if (file.name === fileName) this.applyTagAt(index, tag); + }); + } + + /** The removal half of `applyTagFromDrive`, for DRIVE_TAG_REMOVED. */ + private applyTagRemovalFromDrive(fileName: string, tag: string): void { + this.files().forEach((file, index) => { + if (file.name === fileName) this.dropTagAt(index, tag); + }); + } + + /** Adds `tag` to the file at `fileIndex` in session state and IndexedDB, announcing nothing. False when the index is gone or the tag is already there. */ + private applyTagAt(fileIndex: number, tag: string): boolean { + const file = this.files()[fileIndex]; + if (!file) return false; + if ((file.tags ?? []).includes(tag)) return false; + + return this.writeTagsAt(fileIndex, [...(file.tags ?? []), tag]); + } + + /** Removes `tag` from the file at `fileIndex` in session state and IndexedDB, announcing nothing. False when the index is gone or never carried the tag. */ + private dropTagAt(fileIndex: number, tag: string): boolean { + const file = this.files()[fileIndex]; + if (!file) return false; + if (!(file.tags ?? []).includes(tag)) return false; + + return this.writeTagsAt( + fileIndex, + (file.tags ?? []).filter((t) => t !== tag) + ); + } + + private writeTagsAt(fileIndex: number, tags: string[]): boolean { + const file = this.files()[fileIndex]; + if (!file) return false; + + this.files.update((files) => + files.map((f, i) => (i !== fileIndex ? f : { ...f, tags })) + ); + if (file.dbId !== null) void this.db.updateFileTags(file.dbId, tags); + return true; + } + setActiveHighlight( start: number, end: number, diff --git a/src/app/core/drive.service.spec.ts b/src/app/core/drive.service.spec.ts index 3e23ae4..3ec6c8a 100644 --- a/src/app/core/drive.service.spec.ts +++ b/src/app/core/drive.service.spec.ts @@ -28,8 +28,9 @@ function makeAccountFake(hasDriveFeature = true) { } as unknown as AccountService; } -function makeAppStateFake() { +function makeAppStateFake(loadedFiles: Array<{ tags?: string[] }> = []) { return { + files: signal(loadedFiles), loading: signal(false), loadingMessage: signal(''), showAlert: vi.fn(), @@ -295,6 +296,26 @@ describe('DriveService', () => { ]); } + it('knownTags unions the listing, the loaded files, and the registered vehicle tags', () => { + appState = makeAppStateFake([{ tags: ['local-only'] }]); + const drive = create(); + drive.files.set([ + { + file: { id: '1', name: 'a.json' }, + meta: { date: '', length: '' }, + timestamp: 1, + tags: ['track'], + }, + ]); + + expect(drive.knownTags()).toEqual([ + '175tbi', + '2.0gme', + 'local-only', + 'track', + ]); + }); + it('availableMonths/availableTags list every distinct month and tag, newest month first', () => { const drive = create(); seedDatedFiles(drive); @@ -567,6 +588,159 @@ describe('DriveService', () => { ); }); + it('addTag announces DRIVE_TAG_ADDED once the Drive write lands', async () => { + const update = vi.fn().mockResolvedValue({}); + vi.stubGlobal('gapi', { client: { drive: { files: { update } } } }); + + const drive = create(); + const entry = makeTaggedEntry(); + drive.files.set([entry]); + + const received: unknown[] = []; + bus.on(EVENTS.DRIVE_TAG_ADDED).subscribe((event) => received.push(event)); + + await drive.addTag(entry, '2.0GME'); + + expect(received).toEqual([{ fileName: 'log.json', tag: '2.0gme' }]); + }); + + it('addTag announces nothing when the Drive write fails', async () => { + const update = vi.fn().mockRejectedValue(new Error('nope')); + vi.stubGlobal('gapi', { client: { drive: { files: { update } } } }); + + const drive = create(); + const entry = makeTaggedEntry(); + drive.files.set([entry]); + + const received: unknown[] = []; + bus.on(EVENTS.DRIVE_TAG_ADDED).subscribe((event) => received.push(event)); + + await drive.addTag(entry, 'rain'); + + expect(received).toEqual([]); + }); + + it('addTag canonicalizes a near-miss vehicle tag before writing it', async () => { + const update = vi.fn().mockResolvedValue({}); + vi.stubGlobal('gapi', { client: { drive: { files: { update } } } }); + + const drive = create(); + const entry = makeTaggedEntry(); + drive.files.set([entry]); + + await drive.addTag(entry, '2.0 GME'); + + expect(update).toHaveBeenCalledWith({ + fileId: 'f1', + appProperties: { tags: '2.0gme' }, + }); + }); + + it('removeTag drops one tag, keeping the rest joined', async () => { + const update = vi.fn().mockResolvedValue({}); + vi.stubGlobal('gapi', { client: { drive: { files: { update } } } }); + + const drive = create(); + const entry = makeTaggedEntry(['track', 'rain']); + drive.files.set([entry]); + + await drive.removeTag(entry, 'track'); + + expect(update).toHaveBeenCalledWith({ + fileId: 'f1', + appProperties: { tags: 'rain' }, + }); + expect(drive.files()[0].tags).toEqual(['rain']); + }); + + it('removeTag deletes the appProperties key when the last tag goes', async () => { + const update = vi.fn().mockResolvedValue({}); + vi.stubGlobal('gapi', { client: { drive: { files: { update } } } }); + + const drive = create(); + const entry = makeTaggedEntry(['track']); + drive.files.set([entry]); + + await drive.removeTag(entry, 'track'); + + expect(update).toHaveBeenCalledWith({ + fileId: 'f1', + appProperties: { tags: null }, + }); + }); + + it('removeTag announces DRIVE_TAG_REMOVED once the write lands', async () => { + const update = vi.fn().mockResolvedValue({}); + vi.stubGlobal('gapi', { client: { drive: { files: { update } } } }); + + const drive = create(); + const entry = makeTaggedEntry(['track']); + drive.files.set([entry]); + + const received: unknown[] = []; + bus + .on(EVENTS.DRIVE_TAG_REMOVED) + .subscribe((event) => received.push(event)); + + await drive.removeTag(entry, 'track'); + + expect(received).toEqual([{ fileName: 'log.json', tag: 'track' }]); + }); + + it('removeTag reverts and alerts on API failure, announcing nothing', async () => { + const update = vi.fn().mockRejectedValue(new Error('nope')); + vi.stubGlobal('gapi', { client: { drive: { files: { update } } } }); + + const drive = create(); + const entry = makeTaggedEntry(['track']); + drive.files.set([entry]); + + const received: unknown[] = []; + bus + .on(EVENTS.DRIVE_TAG_REMOVED) + .subscribe((event) => received.push(event)); + + await drive.removeTag(entry, 'track'); + + expect(drive.files()[0].tags).toEqual(['track']); + expect(appState.showAlert).toHaveBeenCalledWith( + expect.stringContaining('Failed to remove tag'), + 'error' + ); + expect(received).toEqual([]); + }); + + it('removeTag ignores a tag the entry does not carry', async () => { + const update = vi.fn(); + vi.stubGlobal('gapi', { client: { drive: { files: { update } } } }); + + const drive = create(); + const entry = makeTaggedEntry(['track']); + drive.files.set([entry]); + + await drive.removeTag(entry, 'rain'); + + expect(update).not.toHaveBeenCalled(); + }); + + it('syncs a FILE_TAG_REMOVED bus event to the matching Drive entry by name', async () => { + const update = vi.fn().mockResolvedValue({}); + vi.stubGlobal('gapi', { client: { drive: { files: { update } } } }); + + const drive = create(); + drive.files.set([makeTaggedEntry(['track', 'rain'])]); + + bus.emit(EVENTS.FILE_TAG_REMOVED, { fileName: 'log.json', tag: 'rain' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(update).toHaveBeenCalledWith({ + fileId: 'f1', + appProperties: { tags: 'track' }, + }); + expect(drive.files()[0].tags).toEqual(['track']); + }); + it('syncs a FILE_TAG_ADDED bus event to the matching Drive entry by name', async () => { const update = vi.fn().mockResolvedValue({}); vi.stubGlobal('gapi', { client: { drive: { files: { update } } } }); diff --git a/src/app/core/drive.service.ts b/src/app/core/drive.service.ts index fd89236..704b8af 100644 --- a/src/app/core/drive.service.ts +++ b/src/app/core/drive.service.ts @@ -6,6 +6,7 @@ import { DataProcessorService } from './data-processor.service'; import { EventBusService } from './event-bus.service'; import { DriveApiFile } from './google-api.types'; import { EVENTS, FileTagAddedEvent } from './models'; +import { VEHICLE_TAGS, canonicalTag } from './vehicle-tags'; const DRIVE_FEATURE_NAME = 'google-drive-access'; @@ -83,6 +84,28 @@ export class DriveService { ); }); + /** + * Every tag worth *suggesting* while typing a new one: the listing's own + * tags, the tags of files already loaded in the session (which need not be + * in the listing at all -- a local file drop carries tags too), and the + * registered vehicle tags, which are worth offering even before any file + * carries one since mistyping them is what silently disables their channel + * sets. + * + * Deliberately *not* what the filter dropdown offers (`availableTags`): + * filtering by a tag no listed file has would just empty the list. + */ + readonly knownTags = computed(() => { + const tags = new Set(VEHICLE_TAGS); + this.files().forEach((item) => + (item.tags ?? []).forEach((t) => tags.add(t)) + ); + this.appState + .files() + .forEach((file) => (file.tags ?? []).forEach((t) => tags.add(t))); + return [...tags].sort(); + }); + /** Port of legacy/src/drive.js's `populateDropdowns` tag list — every tag present in the current listing, alphabetical. */ readonly availableTags = computed(() => { const tags = new Set(); @@ -190,6 +213,11 @@ export class DriveService { this.bus .on(EVENTS.FILE_TAG_ADDED) .subscribe(({ fileName, tag }) => this.syncTagFromChart(fileName, tag)); + this.bus + .on(EVENTS.FILE_TAG_REMOVED) + .subscribe(({ fileName, tag }) => + this.syncTagRemovalFromChart(fileName, tag) + ); } /** @@ -203,6 +231,13 @@ export class DriveService { void this.addTag(entry, tag); } + /** The removal half of `syncTagFromChart`, for FILE_TAG_REMOVED. */ + private syncTagRemovalFromChart(fileName: string, tag: string): void { + const entry = this.files().find((f) => f.file.name === fileName); + if (!entry) return; + void this.removeTag(entry, tag); + } + setSearchTerm(term: string): void { this.searchTerm.set(term); this.currentPage.set(1); @@ -255,9 +290,14 @@ export class DriveService { this.currentPage.update((page) => Math.min(this.totalPages(), page + 1)); } - /** Port of legacy/src/drive.js's `promptAddTag`/`_syncTagFromChart` — no remove-tag UI exists in legacy either. */ + /** + * Port of legacy/src/drive.js's `promptAddTag`/`_syncTagFromChart`. Unlike + * legacy, a successful write is announced on the bus (DRIVE_TAG_ADDED) so a + * tag applied here also reaches the file's loaded copy; see + * AppStateService.applyTagFromDrive. + */ async addTag(entry: DriveFileEntry, rawTag: string): Promise { - const tag = rawTag.trim().toLowerCase(); + const tag = canonicalTag(rawTag); if (!tag) return; const currentTags = entry.tags ?? []; @@ -274,6 +314,14 @@ export class DriveService { fileId: entry.file.id, appProperties: { tags: updatedTags.join(',') }, }); + // Announced only once the write has landed, so a rejected tag never + // reaches the loaded file -- AppStateService applies it without + // emitting FILE_TAG_ADDED back, so this terminates even when the tag + // arrived here via syncTagFromChart in the first place. + this.bus.emit(EVENTS.DRIVE_TAG_ADDED, { + fileName: entry.file.name, + tag, + }); } catch (error) { console.error('Error saving tag:', error); this.setEntryTags(entry.file.id, currentTags); @@ -284,6 +332,47 @@ export class DriveService { } } + /** + * No counterpart in legacy, which could only ever add tags — which was + * survivable while tags were decoration, and isn't now that a mistyped + * vehicle tag (see vehicle-tags.ts) misconfigures the WOT filter's channel + * set with no way back short of editing appProperties in Drive by hand. + * + * Mirrors `addTag` otherwise: optimistic, rolled back on failure, and + * announced only once the write lands. `rawTag` is matched exactly rather + * than canonicalized, since a tag seeded from appProperties may be stored + * in whatever shape it was written there. + */ + async removeTag(entry: DriveFileEntry, rawTag: string): Promise { + const currentTags = entry.tags ?? []; + if (!currentTags.includes(rawTag)) return; + + const updatedTags = currentTags.filter((t) => t !== rawTag); + this.setEntryTags(entry.file.id, updatedTags); + + try { + await window.gapi!.client.drive.files.update({ + fileId: entry.file.id, + // null deletes the property outright; an empty string would leave a + // `tags=` behind that listFiles then parses back to an empty list. + appProperties: { + tags: updatedTags.length > 0 ? updatedTags.join(',') : null, + }, + }); + this.bus.emit(EVENTS.DRIVE_TAG_REMOVED, { + fileName: entry.file.name, + tag: rawTag, + }); + } catch (error) { + console.error('Error removing tag:', error); + this.setEntryTags(entry.file.id, currentTags); + this.appState.showAlert( + `Failed to remove tag from Google Drive: ${(error as Error).message}`, + 'error' + ); + } + } + /** Port of legacy/src/drive.js's `makeFilePublicAndCopyLink`. */ async makeFilePublicAndCopyLink(fileId: string): Promise { this.appState.loading.set(true); diff --git a/src/app/core/google-api.types.ts b/src/app/core/google-api.types.ts index a83af06..7c33f3b 100644 --- a/src/app/core/google-api.types.ts +++ b/src/app/core/google-api.types.ts @@ -37,7 +37,8 @@ interface GapiFilesList { }): Promise; update(args: { fileId: string; - appProperties: Record; + /** A null value deletes that property outright, which is how DriveService.removeTag clears the last tag rather than leaving an empty `tags=` behind. */ + appProperties: Record; }): Promise; } diff --git a/src/app/core/math-channels.service.ts b/src/app/core/math-channels.service.ts index 225d386..9af6fb8 100644 --- a/src/app/core/math-channels.service.ts +++ b/src/app/core/math-channels.service.ts @@ -8,6 +8,7 @@ import { } from './math-definitions'; import { ActionLogEvent, EVENTS, LoadedFile, SignalPoint } from './models'; import { SignalRegistryService } from './signal-registry.service'; +import { VehicleTag, fileHasVehicleTag } from './vehicle-tags'; /** Name prefix `createBatchChannels` gives each generated channel (`Math: ` is added by finalizeChannel) -- also how `resolveBatchSources` maps an auto-enable entry back to its source signal. */ const BATCH_CHANNEL_PREFIX = 'Math: Filtered: '; @@ -179,7 +180,7 @@ export class MathChannelsService { resolveAutoEnableTag( definition: MathDefinition, file: LoadedFile | undefined - ): string | null { + ): VehicleTag | null { return this.matchTagEntry(definition, file)?.[0] ?? null; } @@ -190,16 +191,15 @@ export class MathChannelsService { return this.matchTagEntry(definition, file)?.[1] ?? null; } - /** The `autoEnableSignalsByTag` entry matching one of `file`'s tags (first match wins), or null when none applies. */ + /** The `autoEnableSignalsByTag` entry matching one of `file`'s vehicle tags (first match wins), or null when none applies. */ private matchTagEntry( definition: MathDefinition, file: LoadedFile | undefined - ): [string, string[]] | null { - const tags = file?.tags ?? []; + ): [VehicleTag, string[]] | null { for (const entry of Object.entries( definition.autoEnableSignalsByTag ?? {} - )) { - if (tags.includes(entry[0])) return entry; + ) as Array<[VehicleTag, string[]]>) { + if (fileHasVehicleTag(file, entry[0])) return entry; } return null; } diff --git a/src/app/core/math-definitions.ts b/src/app/core/math-definitions.ts index bddad5a..704bab9 100644 --- a/src/app/core/math-definitions.ts +++ b/src/app/core/math-definitions.ts @@ -1,4 +1,9 @@ import { SignalPoint } from './models'; +import { + VEHICLE_TAG_175TBI, + VEHICLE_TAG_20GME, + VehicleTag, +} from './vehicle-tags'; export interface MathInputOption { value: string; @@ -33,13 +38,14 @@ export interface MathDefinition { autoEnableSignals?: string[]; /** * Per-vehicle-tag overrides for `autoEnableSignals`, keyed by a - * `LoadedFile.tags` entry (seeded from the Drive appProperties the file was - * loaded with). The 175tbi and 2.0gme ECUs expose the same measurements - * under different channel names, so the WOT quick-filter's isolate list has - * to follow the loaded file's tag; the first matching tag wins, and - * `autoEnableSignals` stays the default for files carrying none of them. + * `VehicleTag` (see vehicle-tags.ts — the registry of tags the app branches + * on, seeded from the Drive appProperties the file was loaded with). The + * 175tbi and 2.0gme ECUs expose the same measurements under different + * channel names, so the WOT quick-filter's isolate list has to follow the + * loaded file's tag; the first matching tag wins, and `autoEnableSignals` + * stays the default for files carrying none of them. */ - autoEnableSignalsByTag?: Record; + autoEnableSignalsByTag?: Partial>; autoLoad?: MathAutoLoad; formula?: (values: number[]) => number; customProcess?: ( @@ -843,8 +849,8 @@ export const MATH_DEFINITIONS: MathDefinition[] = [ preSelectAllSources: true, autoEnableSignals: WOT_FILTER_SIGNALS_175TBI, autoEnableSignalsByTag: { - '175tbi': WOT_FILTER_SIGNALS_175TBI, - '2.0gme': WOT_FILTER_SIGNALS_20GME, + [VEHICLE_TAG_175TBI]: WOT_FILTER_SIGNALS_175TBI, + [VEHICLE_TAG_20GME]: WOT_FILTER_SIGNALS_20GME, }, inputs: [ { name: 'sources', label: 'Signals to Filter', isMulti: true }, diff --git a/src/app/core/models.ts b/src/app/core/models.ts index 4fe3de7..d3a7e47 100644 --- a/src/app/core/models.ts +++ b/src/app/core/models.ts @@ -14,6 +14,9 @@ export const EVENTS = { ACTION_LOG: 'action:log', CHART_RESET_ALL: 'chart:reset-all', FILE_TAG_ADDED: 'file:tag-added', + DRIVE_TAG_ADDED: 'drive:tag-added', + FILE_TAG_REMOVED: 'file:tag-removed', + DRIVE_TAG_REMOVED: 'drive:tag-removed', } as const; export interface SignalPoint { @@ -48,6 +51,16 @@ export interface LoadedFile { highlights?: ChartHighlight[]; } +/** + * Payload of all four tag events, which are the two directions of the same + * sync (add and remove), matched by file name in either direction: + * FILE_TAG_ADDED/FILE_TAG_REMOVED announce a change to a loaded file + * (AppStateService) so DriveService can mirror it to the file's Drive + * appProperties, and DRIVE_TAG_ADDED/DRIVE_TAG_REMOVED announce one made + * from the Drive panel (DriveService) so AppStateService can apply it to + * that file if it happens to be loaded. No handler re-emits, which is what + * keeps each pair from echoing. + */ export interface FileTagAddedEvent { fileName: string; tag: string; diff --git a/src/app/core/tags.util.spec.ts b/src/app/core/tags.util.spec.ts new file mode 100644 index 0000000..655dbde --- /dev/null +++ b/src/app/core/tags.util.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeTag, tagStyle } from './tags.util'; + +describe('tags.util', () => { + it('lowercases and trims a typed tag', () => { + expect(normalizeTag(' Track Day ')).toBe('track day'); + }); + + it('collapses runs of whitespace', () => { + expect(normalizeTag('track day')).toBe('track day'); + }); + + it('turns commas into spaces so a tag cannot split on the next Drive load', () => { + expect(normalizeTag('track,rain')).toBe('track rain'); + expect(normalizeTag('track, rain')).toBe('track rain'); + expect(normalizeTag(',track,')).toBe('track'); + }); + + it('normalizes a blank entry to the empty string callers reject', () => { + expect(normalizeTag(' ')).toBe(''); + expect(normalizeTag(',')).toBe(''); + }); + + it('gives a tag the same color every time and different tags different hues', () => { + expect(tagStyle('track')).toBe(tagStyle('track')); + expect(tagStyle('track')).not.toBe(tagStyle('rain')); + }); +}); diff --git a/src/app/core/tags.util.ts b/src/app/core/tags.util.ts new file mode 100644 index 0000000..b2cc2f3 --- /dev/null +++ b/src/app/core/tags.util.ts @@ -0,0 +1,31 @@ +/** + * Shared handling for file tags — the string treatment on the way in, and the + * pill color, both of which used to be duplicated per call site (two + * hand-rolled `.trim().toLowerCase()` chains, two byte-identical copies of + * legacy's `_getTagStyle`). + * + * The tag *registry* — the closed set of tags the app branches on — is a + * separate concern; see vehicle-tags.ts, which builds on `normalizeTag`. + */ + +/** + * Commas are dropped rather than kept, because tags round-trip through Drive + * `appProperties` as one comma-joined string (see DriveService.addTag / + * listFiles): a tag containing a comma would silently come back as two on the + * next load. A comma reads as "and" in a tag box, so it becomes a space here + * rather than being glued out — 'track,rain' normalizes to the single tag + * 'track rain', visibly wrong in the resulting pill rather than corrupt. + */ +export function normalizeTag(raw: string): string { + return raw.replace(/,/g, ' ').trim().toLowerCase().replace(/\s+/g, ' '); +} + +/** Port of legacy/src/chartmanager.js + drive.js's `_getTagStyle` — deterministic hue per tag name, so a tag keeps its color across the chart cards and the Drive panel. */ +export function tagStyle(tag: string): string { + let hash = 0; + for (let i = 0; i < tag.length; i++) { + hash = tag.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = Math.abs(hash) % 360; + return `background: hsla(${hue}, 70%, 50%, 0.15); color: var(--text-primary); border: 1px solid hsla(${hue}, 70%, 50%, 0.3);`; +} diff --git a/src/app/core/vehicle-tags.spec.ts b/src/app/core/vehicle-tags.spec.ts new file mode 100644 index 0000000..1725b8c --- /dev/null +++ b/src/app/core/vehicle-tags.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { LoadedFile } from './models'; +import { + DEFAULT_VEHICLE_TAG, + VEHICLE_TAGS, + canonicalTag, + fileHasVehicleTag, + isVehicleTag, + vehicleTagOf, +} from './vehicle-tags'; + +function makeFile(tags?: string[]): LoadedFile { + return { + name: 'trip.json', + rawData: [], + signals: {}, + startTime: 0, + duration: 10, + availableSignals: [], + metadata: {}, + size: 0, + dbId: 1, + tags, + }; +} + +describe('vehicle-tags', () => { + it('recognizes only the registered tags', () => { + expect(isVehicleTag('2.0gme')).toBe(true); + expect(isVehicleTag('175tbi')).toBe(true); + expect(isVehicleTag('track')).toBe(false); + }); + + it('matches a tag written in a different case or padded, as Drive appProperties can deliver it', () => { + expect(isVehicleTag(' 2.0GME ')).toBe(true); + expect(fileHasVehicleTag(makeFile(['2.0GME']), '2.0gme')).toBe(true); + expect(fileHasVehicleTag(makeFile([' 175TBI']), '175tbi')).toBe(true); + }); + + it('vehicleTagOf returns null for an untagged file or one carrying only free-form labels', () => { + expect(vehicleTagOf(makeFile())).toBeNull(); + expect(vehicleTagOf(makeFile([]))).toBeNull(); + expect(vehicleTagOf(makeFile(['track', 'rain']))).toBeNull(); + expect(vehicleTagOf(undefined)).toBeNull(); + }); + + it('vehicleTagOf picks the tag out of a file that also carries free-form labels', () => { + expect(vehicleTagOf(makeFile(['track', '2.0gme', 'rain']))).toBe('2.0gme'); + }); + + it('vehicleTagOf resolves a file tagged with two profiles by registry order, not file order', () => { + expect(vehicleTagOf(makeFile(['2.0gme', '175tbi']))).toBe('175tbi'); + expect(vehicleTagOf(makeFile(['175tbi', '2.0gme']))).toBe('175tbi'); + }); + + it('matches a vehicle tag typed with a space in it', () => { + expect(isVehicleTag('2.0 gme')).toBe(true); + expect(fileHasVehicleTag(makeFile(['2.0 GME']), '2.0gme')).toBe(true); + expect(fileHasVehicleTag(makeFile(['175 tbi']), '175tbi')).toBe(true); + }); + + it('canonicalTag rewrites a near-miss vehicle tag to its registered spelling', () => { + expect(canonicalTag('2.0 GME')).toBe('2.0gme'); + expect(canonicalTag(' 175 TBI ')).toBe('175tbi'); + }); + + it('canonicalTag leaves a free-form label alone beyond normalizing it', () => { + expect(canonicalTag(' Track Day ')).toBe('track day'); + expect(canonicalTag('rain')).toBe('rain'); + expect(canonicalTag(' ')).toBe(''); + }); + + it('defaults untagged logs to a registered tag', () => { + expect(VEHICLE_TAGS).toContain(DEFAULT_VEHICLE_TAG); + }); +}); diff --git a/src/app/core/vehicle-tags.ts b/src/app/core/vehicle-tags.ts new file mode 100644 index 0000000..77d453c --- /dev/null +++ b/src/app/core/vehicle-tags.ts @@ -0,0 +1,84 @@ +import { LoadedFile } from './models'; +import { normalizeTag } from './tags.util'; + +/** + * The registry of *behavioral* file tags — the small closed set of + * `LoadedFile.tags` entries the app actually branches on, as opposed to the + * free-form labels users attach for their own filtering ('track', 'rain', + * ...), which stay unconstrained and are never listed here. + * + * A tag lands here when some feature reads it: today the WOT quick-filter's + * per-ECU channel sets (`MathDefinition.autoEnableSignalsByTag`) and the + * acceleration Registry's extra run curves (AccelerationService). Both used + * to hard-code the literals independently, so a typo in either place failed + * silently — `autoEnableSignalsByTag` is keyed by `VehicleTag` precisely so + * that now fails the build instead. + * + * Tags reach a file from two directions (see LoadedFile.tags): typed by the + * user, or seeded from the source Drive entry's `appProperties`. Only the + * first path is normalized on the way in, which is why every lookup here + * goes through `matchesTag` rather than a bare `tags.includes()`. + */ +export const VEHICLE_TAGS = ['175tbi', '2.0gme'] as const; + +export type VehicleTag = (typeof VEHICLE_TAGS)[number]; + +export const VEHICLE_TAG_175TBI: VehicleTag = '175tbi'; +export const VEHICLE_TAG_20GME: VehicleTag = '2.0gme'; + +/** + * The profile an untagged log is treated as. The 175tbi channel names came + * first and remain the tag-agnostic default everywhere (see + * WOT_FILTER_SIGNALS_175TBI), so an untagged log keeps its pre-tag behavior. + */ +export const DEFAULT_VEHICLE_TAG: VehicleTag = VEHICLE_TAG_175TBI; + +/** + * Inner whitespace is dropped here, unlike `normalizeTag` — these tags have + * none, so '2.0 gme' and '175 tbi' can only be someone typing the profile + * they meant. Matching them is what keeps a near-miss from silently landing + * as an ordinary label that no feature reads; `canonicalTag` then rewrites it + * so the stored tag is the registered spelling too. + */ +function compact(tag: string): string { + return normalizeTag(tag).replace(/\s+/g, ''); +} + +export function isVehicleTag(tag: string): tag is VehicleTag { + return VEHICLE_TAGS.some((known) => known === compact(tag)); +} + +/** Whether `file` carries `tag`, compared through `compact` on both sides. */ +export function fileHasVehicleTag( + file: LoadedFile | undefined | null, + tag: VehicleTag +): boolean { + return (file?.tags ?? []).some((t) => compact(t) === tag); +} + +/** + * The spelling a raw tag should be *stored* as: normalized, and snapped to + * the registered spelling when it is a variant of a vehicle tag ('2.0 GME' -> + * '2.0gme'). Every add path runs through this, so the typo that used to + * silently disable a vehicle's channel sets can't be persisted. + * + * Not for removal paths, which must match the stored string exactly — a tag + * seeded from Drive appProperties was never normalized on the way in, so + * canonicalizing it there would look up a tag the file does not have. + */ +export function canonicalTag(raw: string): string { + const normalized = normalizeTag(raw); + return VEHICLE_TAGS.find((tag) => tag === compact(normalized)) ?? normalized; +} + +/** + * The vehicle profile `file` is tagged with, or null when it carries none. + * A file tagged with more than one wins on VEHICLE_TAGS order — that + * combination is a user mistake either way, so the point is only that the + * resolution is stable across features rather than per-call-site. + */ +export function vehicleTagOf( + file: LoadedFile | undefined | null +): VehicleTag | null { + return VEHICLE_TAGS.find((tag) => fileHasVehicleTag(file, tag)) ?? null; +}