Skip to content
Merged
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
3 changes: 0 additions & 3 deletions .openhands_instructions

This file was deleted.

1 change: 1 addition & 0 deletions .openhands_instructions
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ Every card (ticket, PR, todo, idea) has exactly one state:

Cards never have colored backgrounds. Color lives only in badges, icons, text, and the amber attention bar. Type badges ("Fehler", "Aufgabe", "User Story") are always neutral stone — only status badges carry semantic colors.

## Commit Hygiene

- Do not commit documentation files, standalone test scripts, or debugging artifacts
- Only commit production code and its corresponding spec files

## Accessibility

- Must pass all AXE checks
Expand Down
74 changes: 74 additions & 0 deletions src/app/jira/jira.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ describe('JiraService', () => {
let httpMock: HttpTestingController;

beforeEach(() => {
localStorage.removeItem('jira.userDisplayNameCache');

TestBed.configureTestingModule({
providers: [provideHttpClient(withFetch()), provideHttpClientTesting()],
});
Expand Down Expand Up @@ -369,4 +371,76 @@ describe('JiraService', () => {
.flush(makeRawIssue({ description: 'Kein Erwähnungen hier.' }));
httpMock.expectNone((r) => r.url.includes('/user'));
});

it('loads user display name cache from localStorage on service initialization', () => {
localStorage.setItem(
'jira.userDisplayNameCache',
JSON.stringify({ u1: 'User One', u2: 'User Two' }),
);

TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [provideHttpClient(withFetch()), provideHttpClientTesting()],
});
const freshService = TestBed.inject(JiraService);
httpMock = TestBed.inject(HttpTestingController);

expect(freshService['userDisplayNameCache'].size).toBe(2);
expect(freshService['userDisplayNameCache'].get('u1')).toBe('User One');
expect(freshService['userDisplayNameCache'].get('u2')).toBe('User Two');
});

it('saves resolved user display names to localStorage', () => {
let result: JiraTicket | undefined;
service.getTicketByKey('TEST-1').subscribe((t) => (result = t));

httpMock
.expectOne((r) => r.url.includes('/issue/TEST-1'))
.flush(makeRawIssue({ description: '[~newuser]' }));
httpMock
.expectOne((r) => r.url.includes('/user') && r.params.get('username') === 'newuser')
.flush({ displayName: 'New User' });

expect(result!.description).toBe('[~New User]');

const parsed = JSON.parse(localStorage.getItem('jira.userDisplayNameCache')!);
expect(parsed['newuser']).toBe('New User');
});

it('uses cached user display names from localStorage instead of making API calls', () => {
localStorage.setItem('jira.userDisplayNameCache', JSON.stringify({ cacheduser: 'Cached User' }));

TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [provideHttpClient(withFetch()), provideHttpClientTesting()],
});
const freshService = TestBed.inject(JiraService);
httpMock = TestBed.inject(HttpTestingController);

let result: JiraTicket | undefined;
freshService.getTicketByKey('TEST-1').subscribe((t) => (result = t));

httpMock
.expectOne((r) => r.url.includes('/issue/TEST-1'))
.flush(makeRawIssue({ description: '[~cacheduser]' }));

httpMock.expectNone((r) => r.url.includes('/user'));
expect(result!.description).toBe('[~Cached User]');
});

it('handles corrupted localStorage cache gracefully', () => {
localStorage.setItem('jira.userDisplayNameCache', 'invalid json data');

TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [provideHttpClient(withFetch()), provideHttpClientTesting()],
});

expect(() => {
const freshService = TestBed.inject(JiraService);
expect(freshService['userDisplayNameCache'].size).toBe(0);
}).not.toThrow();

httpMock = TestBed.inject(HttpTestingController);
});
});
41 changes: 40 additions & 1 deletion src/app/jira/jira.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,44 @@ export class JiraService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.proxyUrl}/jira/rest/api/2`;
private readonly userDisplayNameCache = new Map<string, string>();
private readonly STORAGE_KEY = 'jira.userDisplayNameCache';
private hasLoadedOnce = false;
readonly loading = signal(true);
readonly error = signal(false);
private readonly _tickets = signal<JiraTicket[]>([]);
readonly tickets = this._tickets.asReadonly();

constructor() {
this.loadCacheFromLocalStorage();
}

private loadCacheFromLocalStorage(): void {
try {
const cachedData = localStorage.getItem(this.STORAGE_KEY);
if (cachedData) {
const parsed = JSON.parse(cachedData);
if (typeof parsed === 'object' && parsed !== null) {
Object.entries(parsed).forEach(([slug, displayName]) => {
if (typeof slug === 'string' && typeof displayName === 'string') {
this.userDisplayNameCache.set(slug, displayName);
}
});
}
}
} catch (error) {
console.error('Failed to load user display name cache from localStorage:', error);
}
}

private saveCacheToLocalStorage(): void {
try {
const cacheObject = Object.fromEntries(this.userDisplayNameCache.entries());
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(cacheObject));
} catch (error) {
console.error('Failed to save user display name cache to localStorage:', error);
}
}

loadTickets(): Observable<unknown> {
if (!this.hasLoadedOnce) {
this.loading.set(true);
Expand Down Expand Up @@ -167,9 +199,16 @@ export class JiraService {
),
).pipe(
map((results) => {
let cacheUpdated = false;
results.forEach((result, i) => {
if (result) this.userDisplayNameCache.set(unknown[i], result.displayName);
if (result) {
this.userDisplayNameCache.set(unknown[i], result.displayName);
cacheUpdated = true;
}
});
if (cacheUpdated) {
this.saveCacheToLocalStorage();
}
}),
);
}
Expand Down
Loading