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
>
-
+ @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 @@
+
+
+
+
+
+
+
+
+
+ @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 `