diff --git a/frontend/.gitignore b/frontend/.gitignore
index b482045..5fd4f20 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -4,3 +4,6 @@ dist/
*.local
dev-server*.log
+# Image-generation source files retained locally for asset iteration.
+/src/assets/img/weather-icons/source/
+
diff --git a/frontend/src/assets/img/weather-icons/clear.webp b/frontend/src/assets/img/weather-icons/clear.webp
new file mode 100644
index 0000000..ea4240c
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/clear.webp differ
diff --git a/frontend/src/assets/img/weather-icons/cloudy.webp b/frontend/src/assets/img/weather-icons/cloudy.webp
new file mode 100644
index 0000000..3f2bc55
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/cloudy.webp differ
diff --git a/frontend/src/assets/img/weather-icons/fog.webp b/frontend/src/assets/img/weather-icons/fog.webp
new file mode 100644
index 0000000..c8b0c59
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/fog.webp differ
diff --git a/frontend/src/assets/img/weather-icons/partly_cloudy.webp b/frontend/src/assets/img/weather-icons/partly_cloudy.webp
new file mode 100644
index 0000000..4015ca9
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/partly_cloudy.webp differ
diff --git a/frontend/src/assets/img/weather-icons/rain.webp b/frontend/src/assets/img/weather-icons/rain.webp
new file mode 100644
index 0000000..93b3116
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/rain.webp differ
diff --git a/frontend/src/assets/img/weather-icons/snow.webp b/frontend/src/assets/img/weather-icons/snow.webp
new file mode 100644
index 0000000..2579ced
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/snow.webp differ
diff --git a/frontend/src/assets/img/weather-icons/sunny.webp b/frontend/src/assets/img/weather-icons/sunny.webp
new file mode 100644
index 0000000..d5a8ab3
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/sunny.webp differ
diff --git a/frontend/src/assets/img/weather-icons/thunder.webp b/frontend/src/assets/img/weather-icons/thunder.webp
new file mode 100644
index 0000000..a61da1e
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/thunder.webp differ
diff --git a/frontend/src/components/WeatherConditionIcon.vue b/frontend/src/components/WeatherConditionIcon.vue
new file mode 100644
index 0000000..dd3f182
--- /dev/null
+++ b/frontend/src/components/WeatherConditionIcon.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/stores/weather.test.ts b/frontend/src/stores/weather.test.ts
index e18e846..a58d2f8 100644
--- a/frontend/src/stores/weather.test.ts
+++ b/frontend/src/stores/weather.test.ts
@@ -10,6 +10,7 @@ vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const rawWeather: RawWeatherSnapshot = {
clock: { day: 5, hour: 17, minute: 20, month: 8, year: 2026 },
condition: 'sunny',
+ nextCondition: 'clear',
rainLevel: 0,
region: 'los_santos',
windSpeed: 2,
@@ -32,7 +33,7 @@ describe('weather store', () => {
expect(nuiCall).toHaveBeenCalledWith('weather:get')
expect(weather.forecast?.condition).toBe('sunny')
- expect(weather.forecast?.hourly).toHaveLength(24)
+ expect(weather.forecast?.hourly).toHaveLength(6)
expect(weather.error).toBeNull()
})
diff --git a/frontend/src/types/weather.ts b/frontend/src/types/weather.ts
index a007506..14918a5 100644
--- a/frontend/src/types/weather.ts
+++ b/frontend/src/types/weather.ts
@@ -8,10 +8,7 @@ export type WeatherConditionId =
| 'fog'
| 'snow'
-export type WeatherRegionId =
- | 'los_santos'
- | 'blaine_county'
- | 'cayo_perico'
+export type WeatherRegionId = 'los_santos' | 'blaine_county' | 'cayo_perico'
export type WeatherClock = {
day: number
@@ -24,6 +21,7 @@ export type WeatherClock = {
export type RawWeatherSnapshot = {
clock: WeatherClock
condition: WeatherConditionId
+ nextCondition: WeatherConditionId
rainLevel: number
region: WeatherRegionId
windSpeed: number
@@ -36,17 +34,8 @@ export type HourlyWeather = {
timestamp: number
}
-export type DailyWeather = {
- condition: WeatherConditionId
- high: number
- low: number
- rainChance: number
- timestamp: number
-}
-
export type WeatherForecast = {
condition: WeatherConditionId
- daily: DailyWeather[]
feelsLike: number
hourly: HourlyWeather[]
humidity: number
diff --git a/frontend/src/utils/weather.test.ts b/frontend/src/utils/weather.test.ts
index 412ee6f..91f6132 100644
--- a/frontend/src/utils/weather.test.ts
+++ b/frontend/src/utils/weather.test.ts
@@ -3,12 +3,11 @@ import { describe, expect, it } from 'vitest'
import type { RawWeatherSnapshot, WeatherRegionId } from '@/types/weather'
import { buildWeatherForecast } from './weather'
-function snapshot(
- region: WeatherRegionId = 'los_santos',
-): RawWeatherSnapshot {
+function snapshot(region: WeatherRegionId = 'los_santos'): RawWeatherSnapshot {
return {
clock: { day: 31, hour: 23, minute: 30, month: 12, year: 2026 },
condition: 'partly_cloudy',
+ nextCondition: 'rain',
rainLevel: 0.1,
region,
windSpeed: 4,
@@ -16,13 +15,15 @@ function snapshot(
}
describe('weather forecast', () => {
- it('builds deterministic 24-hour and seven-day forecasts', () => {
+ it('builds a deterministic six-hour forecast from native weather states', () => {
const first = buildWeatherForecast(snapshot())
const second = buildWeatherForecast(snapshot())
expect(first).toEqual(second)
- expect(first.hourly).toHaveLength(24)
- expect(first.daily).toHaveLength(7)
+ expect(first.hourly).toHaveLength(6)
+ expect(first.hourly[0]?.condition).toBe('partly_cloudy')
+ expect(first.hourly[1]?.condition).toBe('rain')
+ expect(first).not.toHaveProperty('daily')
})
it('rolls hourly timestamps into the next year', () => {
diff --git a/frontend/src/utils/weather.ts b/frontend/src/utils/weather.ts
index 49283a6..c049b2d 100644
--- a/frontend/src/utils/weather.ts
+++ b/frontend/src/utils/weather.ts
@@ -1,5 +1,4 @@
import type {
- DailyWeather,
HourlyWeather,
RawWeatherSnapshot,
WeatherConditionId,
@@ -57,37 +56,10 @@ const HUMIDITY: Record = {
snow: 76,
}
-const TRANSITIONS: Record = {
- sunny: ['sunny', 'sunny', 'clear', 'partly_cloudy'],
- clear: ['clear', 'sunny', 'partly_cloudy', 'cloudy'],
- partly_cloudy: ['partly_cloudy', 'clear', 'cloudy', 'rain'],
- cloudy: ['cloudy', 'partly_cloudy', 'rain', 'fog'],
- rain: ['rain', 'cloudy', 'partly_cloudy', 'thunder'],
- thunder: ['rain', 'cloudy', 'thunder', 'partly_cloudy'],
- fog: ['fog', 'cloudy', 'partly_cloudy', 'clear'],
- snow: ['snow', 'cloudy', 'snow', 'partly_cloudy'],
-}
-
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value))
}
-function seedFrom(value: string): number {
- let hash = 2166136261
- for (let index = 0; index < value.length; index += 1) {
- hash ^= value.charCodeAt(index)
- hash = Math.imul(hash, 16777619)
- }
- return hash >>> 0
-}
-
-function random(seed: number): number {
- let value = seed + 0x6d2b79f5
- value = Math.imul(value ^ (value >>> 15), value | 1)
- value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
- return ((value ^ (value >>> 14)) >>> 0) / 4294967296
-}
-
function normalizedCondition(value: string): WeatherConditionId {
return CONDITIONS.includes(value as WeatherConditionId)
? (value as WeatherConditionId)
@@ -109,14 +81,6 @@ function temperatureAt(
)
}
-function nextCondition(
- condition: WeatherConditionId,
- seed: number,
-): WeatherConditionId {
- const choices = TRANSITIONS[condition]
- return choices[Math.floor(random(seed) * choices.length)] ?? condition
-}
-
function snapshotTimestamp(snapshot: RawWeatherSnapshot): number {
const { year, month, day, hour, minute } = snapshot.clock
return Date.UTC(year, month - 1, day, hour, minute)
@@ -126,62 +90,29 @@ export function buildWeatherForecast(
input: RawWeatherSnapshot,
): WeatherForecast {
const condition = normalizedCondition(input.condition)
- const region = input.region in REGION_BASE_TEMPERATURE
- ? input.region
- : 'los_santos'
+ const nextCondition = normalizedCondition(input.nextCondition)
+ const region =
+ input.region in REGION_BASE_TEMPERATURE ? input.region : 'los_santos'
const timestamp = snapshotTimestamp(input)
- const hourSeed = seedFrom(`${region}:${timestamp}:${condition}`)
const hourly: HourlyWeather[] = []
- let forecastCondition = condition
- for (let index = 0; index < 24; index += 1) {
- if (index > 0 && index % 3 === 0) {
- forecastCondition = nextCondition(forecastCondition, hourSeed + index)
- }
+ for (let index = 0; index < 6; index += 1) {
+ const forecastCondition = index === 0 ? condition : nextCondition
const itemTimestamp = timestamp + index * 3_600_000
const itemHour = new Date(itemTimestamp).getUTCHours()
hourly.push({
condition: forecastCondition,
- rainChance: clamp(
- RAIN_CHANCE[forecastCondition] + Math.round(random(hourSeed + index) * 12 - 6),
- 0,
- 100,
- ),
- temperature: temperatureAt(
- region,
- forecastCondition,
- itemHour,
- random(hourSeed + index + 100) * 3 - 1.5,
- ),
+ rainChance: RAIN_CHANCE[forecastCondition],
+ temperature: temperatureAt(region, forecastCondition, itemHour, 0),
timestamp: itemTimestamp,
})
}
- const daily: DailyWeather[] = []
- let dailyCondition = condition
- for (let index = 0; index < 7; index += 1) {
- if (index > 0) dailyCondition = nextCondition(dailyCondition, hourSeed + index * 97)
- const itemTimestamp = timestamp + index * 86_400_000
- const variation = random(hourSeed + index * 31) * 4 - 2
- daily.push({
- condition: dailyCondition,
- high: temperatureAt(region, dailyCondition, 14, variation),
- low: temperatureAt(region, dailyCondition, 4, variation - 1),
- rainChance: clamp(
- RAIN_CHANCE[dailyCondition] + Math.round(random(hourSeed + index * 43) * 10 - 5),
- 0,
- 100,
- ),
- timestamp: itemTimestamp,
- })
- }
-
- const currentVariation = random(hourSeed) * 2 - 1
const temperature = temperatureAt(
region,
condition,
input.clock.hour + input.clock.minute / 60,
- currentVariation,
+ 0,
)
const windSpeed = Math.round(Math.max(0, input.windSpeed) * 3.6)
const nativeRainChance = clamp(Math.round(input.rainLevel * 100), 0, 100)
@@ -189,10 +120,14 @@ export function buildWeatherForecast(
return {
condition,
- daily,
- feelsLike: temperature - Math.round(windSpeed / 18) - (rainChance > 60 ? 1 : 0),
+ feelsLike:
+ temperature - Math.round(windSpeed / 18) - (rainChance > 60 ? 1 : 0),
hourly,
- humidity: clamp(HUMIDITY[condition] + Math.round(input.rainLevel * 8), 0, 100),
+ humidity: clamp(
+ HUMIDITY[condition] + Math.round(input.rainLevel * 8),
+ 0,
+ 100,
+ ),
rainChance,
region,
temperature,
diff --git a/frontend/src/views/apps/WeatherApp.vue b/frontend/src/views/apps/WeatherApp.vue
index 1b5b731..964353c 100644
--- a/frontend/src/views/apps/WeatherApp.vue
+++ b/frontend/src/views/apps/WeatherApp.vue
@@ -1,27 +1,21 @@
@@ -97,7 +54,10 @@ function temperatureRange(day: DailyWeather): Record {
:disabled="weather.isLoading"
@click="weather.refresh(true)"
>
-
+
@@ -108,12 +68,11 @@ function temperatureRange(day: DailyWeather): Record {
{{ phone.t(`Apps.weather.regions.${forecast.region}`) }}
-
{{ forecast.temperature }}°
{{ conditionLabel(forecast.condition) }}
@@ -124,23 +83,42 @@ function temperatureRange(day: DailyWeather): Record {
{{ phone.t('Apps.weather.stale') }}
-
-
+
+
{{ phone.t('Apps.weather.feelsLike') }}
{{ forecast.feelsLike }}°
-
+
{{ phone.t('Apps.weather.wind') }}
{{ forecast.windSpeed }} km/h
-
+
{{ phone.t('Apps.weather.humidity') }}
{{ forecast.humidity }}%
-
+
{{ phone.t('Apps.weather.rain') }}
{{ forecast.rainChance }}%
@@ -160,53 +138,30 @@ function temperatureRange(day: DailyWeather): Record {
class="weather-hour"
>
{{ formatHour(hour.timestamp, index) }}
-
{{ hour.rainChance }}%
{{ hour.temperature }}°
-
-
- {{ phone.t('Apps.weather.daily') }}
-
- {{ formatDay(day, index) }}
-
-
- {{ day.rainChance }}%
-
- {{ day.low }}°
-
-
-
- {{ day.high }}°
-
-
- {{ phone.t(weather.isLoading ? 'Common.loading' : 'Apps.weather.unavailable') }}
-
+ {{
+ phone.t(
+ weather.isLoading ? 'Common.loading' : 'Apps.weather.unavailable',
+ )
+ }}
+
{{ phone.t('Apps.weather.tryAgain') }}
diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs
index c19d5b4..21d31b9 100644
--- a/frontend/testserver/index.cjs
+++ b/frontend/testserver/index.cjs
@@ -933,6 +933,7 @@ app.post('/api/:endpoint', (request, response) => {
data: {
clock: { year: 2026, month: 8, day: 5, hour: 17, minute: 20 },
condition: 'partly_cloudy',
+ nextCondition: 'rain',
rainLevel: 0.08,
region: 'los_santos',
windSpeed: 3.2,
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index 1a632fd..1677f50 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -257,10 +257,12 @@ end
RegisterNUICallback("weather:get", function(_, cb)
local coords = GetEntityCoords(PlayerPedId())
local weather_hash = GetPrevWeatherTypeHashName()
+ local next_weather_hash = GetNextWeatherTypeHashName()
cb({
success = true,
data = {
condition = weather_types[weather_hash] or "clear",
+ nextCondition = weather_types[next_weather_hash] or weather_types[weather_hash] or "clear",
region = weather_region(coords),
clock = {
year = GetClockYear(),