This commit is contained in:
nessar committed 2025-11-08 03:10:03 +01:00
1 parent 456a1fffdd
commit 48c6d4b16f
12 files changed
+188 -180

No files matched your search

+28 -18
View File
@@ -5,6 +5,7 @@ This application implements a comprehensive multi-layer image caching strategy f
## Caching Layers
### 1. HTTP Cache Headers (`image-cache.interceptor.ts`)
- Intercepts HTTP requests for images
- Adds `Cache-Control: public, max-age=86400, immutable` headers
- Enables browser HTTP cache for 24 hours
@@ -12,26 +13,29 @@ This application implements a comprehensive multi-layer image caching strategy f
- Specifically targets League of Legends CDN URLs
### 2. Cache API (`image-preload.service.ts`)
- Uses browser Cache API for persistent storage
- Cache name: `lol-champion-images-v1`
- Cache expiry: 7 days
- Features:
- Preloads images in batches (10 images per batch)
- Uses `requestIdleCallback` for non-blocking preload
- Automatic cleanup of expired cache entries
- Adds custom metadata (`x-cached-date`) to track cache age
- Checks cache before fetching from network
- Preloads images in batches (10 images per batch)
- Uses `requestIdleCallback` for non-blocking preload
- Automatic cleanup of expired cache entries
- Adds custom metadata (`x-cached-date`) to track cache age
- Checks cache before fetching from network
### 3. Service Worker (Production Only)
- Angular Service Worker configuration (`ngsw-config.json`)
- Two data groups:
- **champion-api**: Caches API responses for 1 day
- **champion-images**: Caches champion images for 7 days
- **champion-api**: Caches API responses for 1 day
- **champion-images**: Caches champion images for 7 days
- Strategy: `performance` (cache-first, network fallback)
- Only active in production builds (`!isDevMode()`)
- Registers 30 seconds after app stability
### 4. Local Storage (`champion-data.service.ts`)
- Caches champion data (not images) for 24 hours
- Prevents unnecessary API calls
- Version-controlled cache with automatic invalidation
@@ -39,32 +43,36 @@ This application implements a comprehensive multi-layer image caching strategy f
## How It Works
1. **First Load**:
- App fetches champion data from API
- Data is cached in LocalStorage
- Images are preloaded in background using Cache API
- HTTP cache headers enable browser caching
- App fetches champion data from API
- Data is cached in LocalStorage
- Images are preloaded in background using Cache API
- HTTP cache headers enable browser caching
2. **Subsequent Loads**:
- Champion data loaded from LocalStorage
- Images loaded from Cache API (instant)
- If cache miss, browser HTTP cache serves images
- If HTTP cache miss, network fetch with automatic caching
- Champion data loaded from LocalStorage
- Images loaded from Cache API (instant)
- If cache miss, browser HTTP cache serves images
- If HTTP cache miss, network fetch with automatic caching
3. **Production Builds**:
- Service Worker adds additional caching layer
- Offline support for previously loaded images
- Automatic cache updates on new versions
- Service Worker adds additional caching layer
- Offline support for previously loaded images
- Automatic cache updates on new versions
## Cache Management
### Clear Image Cache
The `ImagePreloadService` provides a `clearCache()` method:
```typescript
inject(ImagePreloadService).clearCache();
```
### Clear Champion Data Cache
The `ChampionDataService` provides a `clearCache()` method:
```typescript
inject(ChampionDataService).clearCache();
```
@@ -86,11 +94,13 @@ inject(ChampionDataService).clearCache();
## Development vs Production
**Development** (ng serve):
- HTTP cache only
- Cache API preloading
- No service worker
**Production** (ng build):
- All caching layers active
- Service worker enabled
- Maximum performance and offline support
+4
View File
@@ -5,11 +5,13 @@ This project uses a proxy to avoid CORS issues when fetching champion data from
## Architecture
### Development (`ng serve`)
- Uses `proxy.conf.json` configuration
- Angular CLI dev server handles the proxy
- Requests to `/api/champions` are forwarded to the external CDN
### Production/SSR (`ng build` + `npm run serve:ssr:random-lol`)
- Uses `http-proxy-middleware` in `src/server.ts`
- Express server handles the proxy
- Same endpoint `/api/champions` works consistently
@@ -31,12 +33,14 @@ This project uses a proxy to avoid CORS issues when fetching champion data from
## Testing
### Development
```bash
npm start
# Visit http://localhost:4200
```
### Production SSR
```bash
npm run build
npm run serve:ssr:random-lol
+27 -27
View File
@@ -4,31 +4,31 @@ import prettier from 'eslint-config-prettier';
import prettierPlugin from 'eslint-plugin-prettier';
export default [
eslintJs.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
files: ['**/*.ts'],
plugins: {
prettier: prettierPlugin,
},
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'prettier/prettier': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
{
ignores: ['dist/**', 'node_modules/**', '.angular/**', 'coverage/**'],
},
eslintJs.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
files: ['**/*.ts'],
plugins: {
prettier: prettierPlugin,
},
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'prettier/prettier': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
{
ignores: ['dist/**', 'node_modules/**', '.angular/**', 'coverage/**'],
},
];
+3 -3
View File
@@ -134,7 +134,7 @@ export class ChampionDataService {
try {
const response = await firstValueFrom(
this.http.get<Record<string, RemoteChampion>>(CHAMPION_DATA_URL)
this.http.get<Record<string, RemoteChampion>>(CHAMPION_DATA_URL),
);
const champions = Object.entries(response)
@@ -174,8 +174,8 @@ export class ChampionDataService {
new Set(
data.positions
.map((position) => POSITION_TO_LANE[position])
.filter((role): role is Lane => role !== undefined)
)
.filter((role): role is Lane => role !== undefined),
),
);
if (roles.length === 0 || !data.icon) {
+75 -71
View File
@@ -16,85 +16,89 @@
<span>Each roll keeps champions unique across every lane.</span>
</div>
@if (isLoading()) {
<div class="status mat-body-small" aria-live="polite">
<mat-icon aria-hidden="true">autorenew</mat-icon>
<span>Loading champion data&hellip;</span>
</div>
<div class="status mat-body-small" aria-live="polite">
<mat-icon aria-hidden="true">autorenew</mat-icon>
<span>Loading champion data&hellip;</span>
</div>
} @else if (loadError(); as errorMessage) {
<div class="status error mat-body-small" aria-live="polite">
<mat-icon aria-hidden="true" color="warn">error</mat-icon>
<span>{{ errorMessage }}</span>
<button type="button" mat-button color="accent" (click)="retryLoad()">
<mat-icon aria-hidden="true">refresh</mat-icon>
<span>Retry</span>
</button>
</div>
<div class="status error mat-body-small" aria-live="polite">
<mat-icon aria-hidden="true" color="warn">error</mat-icon>
<span>{{ errorMessage }}</span>
<button type="button" mat-button color="accent" (click)="retryLoad()">
<mat-icon aria-hidden="true">refresh</mat-icon>
<span>Retry</span>
</button>
</div>
}
</header>
<div class="lane-grid">
@for (assignment of laneAssignments(); track assignment.lane) {
<mat-card class="lane-card">
<mat-card-header>
<mat-card-title>{{ laneLabel(assignment.lane) }}</mat-card-title>
<mat-card-subtitle>
{{
assignment.champion
? 'Unique pick locked in'
: 'Champion needed for this lane'
}}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
@if (assignment.champion; as champion) {
<div class="champion-details">
<img
[ngSrc]="champion.icon"
width="96"
height="96"
[class.loading-image]="isLoading()"
[alt]="champion.name + ' portrait'"
[priority]="true"
class="champion-icon"
/>
<p class="champion-name mat-title-medium">{{ champion.name }}</p>
</div>
<mat-chip-set class="roles-set" [attr.aria-label]="'Roles for ' + champion.name">
@for (role of champion.roles; track role) {
<mat-chip>{{ roleLabel(role) }}</mat-chip>
<mat-card class="lane-card">
<mat-card-header>
<mat-card-title>{{ laneLabel(assignment.lane) }}</mat-card-title>
<mat-card-subtitle>
{{
assignment.champion
? 'Unique pick locked in'
: 'Champion needed for this lane'
}}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
@if (assignment.champion; as champion) {
<div class="champion-details">
<img
[ngSrc]="champion.icon"
width="96"
height="96"
[class.loading-image]="isLoading()"
[alt]="champion.name + ' portrait'"
[priority]="true"
class="champion-icon"
/>
<p class="champion-name mat-title-medium">{{ champion.name }}</p>
</div>
<mat-chip-set
class="roles-set"
[attr.aria-label]="'Roles for ' + champion.name"
>
@for (role of champion.roles; track role) {
<mat-chip>{{ roleLabel(role) }}</mat-chip>
}
</mat-chip-set>
} @else {
<p class="champion-name champion-name-empty mat-title-medium">
No champion available
</p>
<p class="champion-roles mat-body-small">
Roll again once champions finish loading.
</p>
}
</mat-chip-set>
} @else {
<p class="champion-name champion-name-empty mat-title-medium">
No champion available
</p>
<p class="champion-roles mat-body-small">
Roll again once champions finish loading.
</p>
}
<button
type="button"
matFab
extended
color="accent"
class="change-champion-button"
[disabled]="isLoading() || !hasChampions()"
(click)="changeChampion(assignment.lane)"
>
<mat-icon aria-hidden="true">refresh</mat-icon>
Re-Roll ?
</button>
</mat-card-content>
</mat-card>
<button
type="button"
matFab
extended
color="accent"
class="change-champion-button"
[disabled]="isLoading() || !hasChampions()"
(click)="changeChampion(assignment.lane)"
>
<mat-icon aria-hidden="true">refresh</mat-icon>
Re-Roll ?
</button>
</mat-card-content>
</mat-card>
}
</div>
@if (hasMissingChampion()) {
<mat-card class="warning-card">
<mat-card-content>
<mat-icon aria-hidden="true" color="warn">warning</mat-icon>
<span>
Unable to assign a unique champion to every lane. Add more champions to the pool.
</span>
</mat-card-content>
</mat-card>
<mat-card class="warning-card">
<mat-card-content>
<mat-icon aria-hidden="true" color="warn">warning</mat-icon>
<span>
Unable to assign a unique champion to every lane. Add more champions to the
pool.
</span>
</mat-card-content>
</mat-card>
}
</section>
+4 -4
View File
@@ -55,7 +55,7 @@ export class ChampionRandomizerComponent {
protected readonly hasChampions = computed(() => this.champions().length > 0);
protected readonly assignments = signal<Record<Lane, Champion | null>>(
this.createEmptyAssignments()
this.createEmptyAssignments(),
);
protected readonly usedChampions = computed<Record<Lane, Champion | null>>(() => {
@@ -111,11 +111,11 @@ export class ChampionRandomizerComponent {
champion,
rolesText,
} satisfies LaneAssignmentView;
})
}),
);
protected readonly hasMissingChampion = computed(() =>
this.lanes.some((lane) => this.assignments()[lane] === null)
this.lanes.some((lane) => this.assignments()[lane] === null),
);
constructor() {
@@ -154,7 +154,7 @@ export class ChampionRandomizerComponent {
for (const lane of this.lanes) {
const candidates = (championsByLaneMap.get(lane) || []).filter(
(champion) => !selected.has(champion.name)
(champion) => !selected.has(champion.name),
);
if (candidates.length === 0) {
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
+2 -2
View File
@@ -36,7 +36,7 @@ app.use(
pathRewrite: {
'^.*': '',
},
})
}),
);
/**
@@ -47,7 +47,7 @@ app.use(
maxAge: '1y',
index: false,
redirect: false,
})
}),
);
/**
+1 -1
View File
@@ -7,5 +7,5 @@ body {
body {
margin: 0;
font-family: Roboto, "Helvetica Neue", sans-serif;
font-family: Roboto, 'Helvetica Neue', sans-serif;
}
+7 -13
View File
@@ -1,17 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": [
"node"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}
+30 -30
View File
@@ -1,34 +1,34 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+6 -10
View File
@@ -1,14 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.ts"
]
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["jasmine"]
},
"include": ["src/**/*.ts"]
}