Skip to content
Open
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
559 changes: 255 additions & 304 deletions apps/angular/package-lock.json

Large diffs are not rendered by default.

24 changes: 10 additions & 14 deletions apps/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,18 @@
"test:ui": "playwright test --ui"
},
"dependencies": {
"@angular/animations": "^22.0.0",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/platform-browser-dynamic": "^22.0.0",
"@angular/router": "^22.0.0",
"rxjs": "~7.8.0",
"@angular/common": "^22.0.4",
"@angular/compiler": "^22.0.4",
"@angular/core": "^22.0.4",
"@angular/platform-browser": "^22.0.4",
"rxjs": "~7.8.2",
"tslib": "^2.8.1"
},
"devDependencies": {
"@angular/build": "^22.0.0",
"@angular/cli": "^22.0.0",
"@angular/compiler-cli": "^22.0.0",
"@playwright/test": "^1.40.0",
"@angular/build": "^22.0.4",
"@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.4",
"@playwright/test": "^1.61.1",
"typescript": "~6.0.3"
},
"keywords": [
Expand All @@ -35,4 +31,4 @@
],
"author": "",
"license": "MIT"
}
}
28 changes: 10 additions & 18 deletions apps/angular/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';

import { Component, inject } from '@angular/core';
import { WeatherStateService } from './services/weather-state.service';

import { SearchFormComponent } from './components/search-form.component';
import { LoadingStateComponent } from './components/loading-state.component';
import { ErrorStateComponent } from './components/error-state.component';
import { WeatherContentComponent } from './components/weather-content.component';

@Component({
selector: 'app-root',
standalone: true,
imports: [
SearchFormComponent,
LoadingStateComponent,
ErrorStateComponent,
WeatherContentComponent
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header class="header">
<div class="container">
Expand All @@ -26,11 +22,11 @@ import { WeatherContentComponent } from './components/weather-content.component'

<main class="main">
<div class="container">
@let currentState = state();
@let currentState = weatherStateService.state();

<app-search-form
[isLoading]="currentState.isLoading"
(search)="onSearch($event)"
(search)="weatherStateService.loadWeather($event)"
></app-search-form>

<div class="weather-container" data-testid="weather-container">
Expand All @@ -41,10 +37,12 @@ import { WeatherContentComponent } from './components/weather-content.component'
[message]="currentState.error"
></app-error-state>

<app-weather-content
[isVisible]="!!currentState.weatherData && !currentState.isLoading && !currentState.error"
[weatherData]="currentState.weatherData"
></app-weather-content>
@defer (on immediate) {
<app-weather-content
[isVisible]="!!currentState.weatherData && !currentState.isLoading && !currentState.error"
[weatherData]="currentState.weatherData"
></app-weather-content>
}
</div>
</div>
</main>
Expand All @@ -62,11 +60,5 @@ import { WeatherContentComponent } from './components/weather-content.component'
`
})
export class AppComponent {
private readonly weatherStateService = inject(WeatherStateService);

protected readonly state = this.weatherStateService.state;

onSearch(city: string): void {
this.weatherStateService.loadWeather(city);
}
protected readonly weatherStateService = inject(WeatherStateService);
}
5 changes: 1 addition & 4 deletions apps/angular/src/app/components/current-weather.component.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { Component, computed, input } from '@angular/core';
import { WeatherData } from '../types/weather.types';
import { WeatherUtils } from '../utils/weather.utils';

@Component({
selector: 'app-current-weather',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (weatherData(); as weatherData) {
<section class="current-section">
Expand Down
5 changes: 1 addition & 4 deletions apps/angular/src/app/components/error-state.component.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { Component, input } from '@angular/core';

@Component({
selector: 'app-error-state',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="error"
Expand Down
5 changes: 1 addition & 4 deletions apps/angular/src/app/components/forecast-item.component.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { Component, computed, input, output } from '@angular/core';
import { DailyWeather } from '../types/weather.types';
import { WeatherUtils } from '../utils/weather.utils';

@Component({
selector: 'app-forecast-item',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="forecast-item"
Expand Down
7 changes: 3 additions & 4 deletions apps/angular/src/app/components/forecast.component.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { ChangeDetectionStrategy, Component, ElementRef, afterNextRender, inject, input, signal } from '@angular/core';
import { Component, ElementRef, Injector, afterNextRender, inject, input, signal } from '@angular/core';
import { WeatherData } from '../types/weather.types';
import { ForecastItemComponent } from './forecast-item.component';

@Component({
selector: 'app-forecast',
standalone: true,
imports: [ForecastItemComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (weatherData(); as weatherData) {
<section class="forecast-section">
Expand All @@ -29,6 +27,7 @@ import { ForecastItemComponent } from './forecast-item.component';
})
export class ForecastComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly injector = inject(Injector);

readonly weatherData = input<WeatherData | null>(null);
readonly activeForecastIndex = signal<number | null>(null);
Expand All @@ -43,7 +42,7 @@ export class ForecastComponent {
if (activeElement) {
activeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
}, { injector: this.injector });
}
}
}
5 changes: 1 addition & 4 deletions apps/angular/src/app/components/loading-state.component.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { Component, input } from '@angular/core';

@Component({
selector: 'app-loading-state',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="loading"
Expand Down
16 changes: 6 additions & 10 deletions apps/angular/src/app/components/search-form.component.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import { ChangeDetectionStrategy, Component, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Component, input, output, signal } from '@angular/core';

@Component({
selector: 'app-search-form',
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="search-section">
<form class="search-form" data-testid="search-form" (ngSubmit)="onSubmit()">
<form class="search-form" data-testid="search-form" (submit)="onSubmit($event)">
<div class="search-form__group">
<label for="location-input" class="sr-only">Enter city name</label>
<input
Expand All @@ -19,9 +15,8 @@ import { FormsModule } from '@angular/forms';
placeholder="Enter city name..."
data-testid="search-input"
autocomplete="off"
[ngModel]="city()"
(ngModelChange)="city.set($event)"
name="city"
[value]="city()"
(input)="city.set(locationInput.value)"
/>
<button
type="submit"
Expand All @@ -44,7 +39,8 @@ export class SearchFormComponent {
readonly search = output<string>();
readonly city = signal(this.getSavedLocation() ?? 'London');

onSubmit(): void {
onSubmit(event: SubmitEvent): void {
event.preventDefault();
const city = this.city().trim();

if (!city) {
Expand Down
4 changes: 1 addition & 3 deletions apps/angular/src/app/components/weather-content.component.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { Component, input } from '@angular/core';
import { WeatherData } from '../types/weather.types';
import { CurrentWeatherComponent } from './current-weather.component';
import { ForecastComponent } from './forecast.component';

@Component({
selector: 'app-weather-content',
standalone: true,
imports: [CurrentWeatherComponent, ForecastComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="weather-content"
Expand Down
10 changes: 5 additions & 5 deletions apps/angular/src/app/services/weather-state.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Injectable, signal } from '@angular/core';
import { Service, signal, inject } from '@angular/core';
import { EMPTY, of } from 'rxjs';
import { catchError, finalize, delay, switchMap } from 'rxjs/operators';
import { WeatherService } from './weather.service';
import { AppState } from '../types/weather.types';

@Injectable({
providedIn: 'root'
})
@Service()
export class WeatherStateService {
private readonly weatherService = inject(WeatherService);

private readonly stateSignal = signal<AppState>({
weatherData: null,
isLoading: false,
Expand All @@ -16,7 +16,7 @@ export class WeatherStateService {

readonly state = this.stateSignal.asReadonly();

constructor(private weatherService: WeatherService) {
constructor() {
this.initializeApp();
}

Expand Down
44 changes: 23 additions & 21 deletions apps/angular/src/app/services/weather.service.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
import { Injectable } from '@angular/core';
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, throwError, of } from 'rxjs';
import { catchError, map, switchMap, delay } from 'rxjs/operators';
import { WeatherData, GeocodingResult } from '../types/weather.types';

@Injectable({
providedIn: 'root'
})
@Service()
export class WeatherService {
private readonly http = inject(HttpClient);
private readonly baseUrl = 'https://api.open-meteo.com/v1';
private readonly geocodingUrl = 'https://geocoding-api.open-meteo.com/v1';
private readonly useMockData: boolean;

constructor(private http: HttpClient) {
constructor() {
this.useMockData = this.shouldUseMockData();
}

private shouldUseMockData(): boolean {
// Check if we're in a testing environment (Playwright sets specific user agents)
const isTestEnvironment = navigator.userAgent.includes('Playwright') ||
navigator.userAgent.includes('HeadlessChrome');
navigator.userAgent.includes('HeadlessChrome');

// Don't use mock data if we're explicitly testing API errors
if (window.location.search.includes('mock=false')) {
Expand All @@ -42,7 +41,7 @@ export class WeatherService {

private isTestEnvironment(): boolean {
return navigator.userAgent.includes('Playwright') ||
navigator.userAgent.includes('HeadlessChrome');
navigator.userAgent.includes('HeadlessChrome');
}

private getMockGeocodingData(cityName: string): GeocodingResult {
Expand Down Expand Up @@ -94,9 +93,14 @@ export class WeatherService {
return of(this.getMockGeocodingData(cityName));
}

const url = `${this.geocodingUrl}/search?name=${encodeURIComponent(cityName)}&count=1&language=en&format=json`;

return this.http.get<{ results: GeocodingResult[] }>(url).pipe(
return this.http.get<{ results: GeocodingResult[] }>(`${this.geocodingUrl}/search`, {
params: {
name: cityName,
count: '1',
language: 'en',
format: 'json'
}
}).pipe(
map((response: { results: GeocodingResult[] }) => {
if (!response.results || response.results.length === 0) {
throw new Error('Location not found');
Expand All @@ -115,17 +119,15 @@ export class WeatherService {
return this.getMockData();
}

const params = new URLSearchParams({
latitude: latitude.toString(),
longitude: longitude.toString(),
daily: 'temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset,rain_sum,uv_index_max,precipitation_probability_max',
current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,snowfall,showers,rain,precipitation,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_direction_10m,wind_gusts_10m,wind_speed_10m',
timezone: 'GMT'
});

const url = `${this.baseUrl}/forecast?${params}`;

return this.http.get<WeatherData>(url).pipe(
return this.http.get<WeatherData>(`${this.baseUrl}/forecast`, {
params: {
latitude: latitude.toString(),
longitude: longitude.toString(),
daily: 'temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset,rain_sum,uv_index_max,precipitation_probability_max',
current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,snowfall,showers,rain,precipitation,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_direction_10m,wind_gusts_10m,wind_speed_10m',
timezone: 'GMT'
}
}).pipe(
catchError(error => {
console.error('Weather API error:', error);
return throwError(() => new Error('Unable to fetch weather data. Please try again later.'));
Expand Down
9 changes: 1 addition & 8 deletions apps/angular/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(withFetch())
]
}).catch(err => console.error(err));
bootstrapApplication(AppComponent).catch(err => console.error(err));