Merge branch 'dev' into feature/funk

This commit is contained in:
Alec Schitzkat
2026-08-09 00:42:49 +02:00
45 changed files with 3742 additions and 476 deletions
+1
View File
@@ -73,6 +73,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
calendar: { enabled: true, sounds: true },
weather: { enabled: true, sounds: true },
banking: { enabled: true, sounds: true },
garage: { enabled: true, sounds: true },
mail: { enabled: true, sounds: true },
map: { enabled: true, sounds: true },
notes: { enabled: true, sounds: true },
+7 -6
View File
@@ -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', () => {
+15 -80
View File
@@ -1,5 +1,4 @@
import type {
DailyWeather,
HourlyWeather,
RawWeatherSnapshot,
WeatherConditionId,
@@ -57,37 +56,10 @@ const HUMIDITY: Record<WeatherConditionId, number> = {
snow: 76,
}
const TRANSITIONS: Record<WeatherConditionId, WeatherConditionId[]> = {
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,