ADD - homescreen and apps

This commit is contained in:
Eichenholz
2026-08-03 15:47:09 +02:00
parent 1dd1d05afd
commit 6addba279d
47 changed files with 3141 additions and 139 deletions
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
calculate,
chooseCalculatorOperator,
clearCalculator,
inputDigit,
resolveCalculator,
} from './calculator'
describe('calculator', () => {
it('calculates all four operations', () => {
expect(calculate(8, 2, 'add')).toBe(10)
expect(calculate(8, 2, 'subtract')).toBe(6)
expect(calculate(8, 2, 'multiply')).toBe(16)
expect(calculate(8, 2, 'divide')).toBe(4)
})
it('chains operations and handles division by zero', () => {
let state = inputDigit(clearCalculator(), '8')
state = chooseCalculatorOperator(state, 'add')
state = inputDigit(state, '2')
state = chooseCalculatorOperator(state, 'multiply')
expect(state.display).toBe('10')
state = inputDigit(state, '3')
expect(resolveCalculator(state).display).toBe('30')
expect(calculate(4, 0, 'divide')).toBeNull()
})
})
+126
View File
@@ -0,0 +1,126 @@
export type CalculatorOperator = 'add' | 'subtract' | 'multiply' | 'divide'
export type CalculatorState = {
accumulator: number | null
display: string
error: boolean
pendingOperator: CalculatorOperator | null
waitingForOperand: boolean
}
export const INITIAL_CALCULATOR_STATE: CalculatorState = {
accumulator: null,
display: '0',
error: false,
pendingOperator: null,
waitingForOperand: false,
}
function formatNumber(value: number): string {
if (!Number.isFinite(value)) return 'Error'
const rounded = Number(value.toPrecision(12))
const rendered = String(rounded)
return rendered.length <= 12 ? rendered : rounded.toExponential(6)
}
export function calculate(
left: number,
right: number,
operator: CalculatorOperator,
): number | null {
if (operator === 'add') return left + right
if (operator === 'subtract') return left - right
if (operator === 'multiply') return left * right
if (right === 0) return null
return left / right
}
export function clearCalculator(): CalculatorState {
return { ...INITIAL_CALCULATOR_STATE }
}
export function inputDigit(
state: CalculatorState,
digit: string,
): CalculatorState {
if (!/^\d$/.test(digit)) return state
if (state.error || state.waitingForOperand) {
return { ...state, display: digit, error: false, waitingForOperand: false }
}
if (state.display === '0') return { ...state, display: digit }
if (state.display.replace('-', '').replace('.', '').length >= 10) return state
return { ...state, display: `${state.display}${digit}` }
}
export function inputDecimal(state: CalculatorState): CalculatorState {
if (state.error || state.waitingForOperand) {
return { ...state, display: '0.', error: false, waitingForOperand: false }
}
return state.display.includes('.')
? state
: { ...state, display: `${state.display}.` }
}
export function toggleCalculatorSign(state: CalculatorState): CalculatorState {
if (state.error || state.display === '0') return state
return {
...state,
display: state.display.startsWith('-')
? state.display.slice(1)
: `-${state.display}`,
}
}
export function calculatorPercent(state: CalculatorState): CalculatorState {
if (state.error) return state
return { ...state, display: formatNumber(Number(state.display) / 100) }
}
export function chooseCalculatorOperator(
state: CalculatorState,
operator: CalculatorOperator,
): CalculatorState {
if (state.error) return clearCalculator()
const input = Number(state.display)
let accumulator = state.accumulator
if (
accumulator !== null &&
state.pendingOperator &&
!state.waitingForOperand
) {
const result = calculate(accumulator, input, state.pendingOperator)
if (result === null)
return { ...clearCalculator(), display: 'Error', error: true }
accumulator = result
} else if (accumulator === null) {
accumulator = input
}
return {
accumulator,
display: formatNumber(accumulator),
error: false,
pendingOperator: operator,
waitingForOperand: true,
}
}
export function resolveCalculator(state: CalculatorState): CalculatorState {
if (state.error || state.accumulator === null || !state.pendingOperator)
return state
const result = calculate(
state.accumulator,
Number(state.display),
state.pendingOperator,
)
if (result === null)
return { ...clearCalculator(), display: 'Error', error: true }
return {
accumulator: null,
display: formatNumber(result),
error: false,
pendingOperator: null,
waitingForOperand: true,
}
}
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { elapsedMilliseconds, remainingMilliseconds } from './clock'
describe('timestamp clocks', () => {
it('derives elapsed and remaining time without an interval state', () => {
expect(elapsedMilliseconds(500, 1000, 2500)).toBe(2000)
expect(remainingMilliseconds(5000, 1000, 2500)).toBe(3500)
expect(remainingMilliseconds(100, 1000, 2500)).toBe(0)
})
})
+33
View File
@@ -0,0 +1,33 @@
export function elapsedMilliseconds(
accumulated: number,
startedAt: number | null,
now: number,
): number {
return Math.max(0, accumulated + (startedAt === null ? 0 : now - startedAt))
}
export function remainingMilliseconds(
remainingAtStart: number,
startedAt: number | null,
now: number,
): number {
return Math.max(
0,
remainingAtStart - (startedAt === null ? 0 : now - startedAt),
)
}
export function formatStopwatch(milliseconds: number): string {
const totalCentiseconds = Math.floor(milliseconds / 10)
const minutes = Math.floor(totalCentiseconds / 6000)
const seconds = Math.floor((totalCentiseconds % 6000) / 100)
const centiseconds = totalCentiseconds % 100
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centiseconds).padStart(2, '0')}`
}
export function formatTimer(milliseconds: number): string {
const totalSeconds = Math.ceil(milliseconds / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { clampPage } from './pages'
describe('page clamping', () => {
it('keeps pages in range', () => {
expect(clampPage(-4)).toBe(0)
expect(clampPage(1)).toBe(1)
expect(clampPage(9)).toBe(2)
})
})
+9
View File
@@ -0,0 +1,9 @@
export const SPRINGBOARD_PAGE_COUNT = 3
export function clampPage(
page: number,
pageCount = SPRINGBOARD_PAGE_COUNT,
): number {
if (!Number.isFinite(page) || pageCount <= 0) return 0
return Math.min(pageCount - 1, Math.max(0, Math.round(page)))
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_PHONE_PREFERENCES, parsePhonePreferences } from './preferences'
describe('preferences', () => {
it('falls back for malformed and obsolete records', () => {
expect(parsePhonePreferences('{')).toEqual(DEFAULT_PHONE_PREFERENCES)
expect(parsePhonePreferences('{"version":2}')).toEqual(
DEFAULT_PHONE_PREFERENCES,
)
})
it('keeps valid harmless preferences', () => {
const value = parsePhonePreferences(
JSON.stringify({
version: 1,
settings: { wifi: false, wallpaper: 'ember' },
}),
)
expect(value.settings.wifi).toBe(false)
expect(value.settings.wallpaper).toBe('ember')
})
})
+87
View File
@@ -0,0 +1,87 @@
export const PHONE_PREFERENCES_KEY = 'sky_phone.preferences.v1'
export type WallpaperId = 'midnight' | 'aurora' | 'ember'
export type PhonePreferencesV1 = {
settings: {
airplaneMode: boolean
bluetooth: boolean
cellular: boolean
personalHotspot: boolean
vpn: boolean
wallpaper: WallpaperId
wifi: boolean
}
version: 1
}
export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
settings: {
airplaneMode: false,
bluetooth: true,
cellular: true,
personalHotspot: false,
vpn: false,
wallpaper: 'midnight',
wifi: true,
},
version: 1,
}
export const WALLPAPER_IDS: WallpaperId[] = ['midnight', 'aurora', 'ember']
export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
if (!raw) return structuredClone(DEFAULT_PHONE_PREFERENCES)
try {
const parsed = JSON.parse(raw) as Partial<PhonePreferencesV1>
const settings = parsed.settings
if (parsed.version !== 1 || !settings || typeof settings !== 'object') {
return structuredClone(DEFAULT_PHONE_PREFERENCES)
}
const defaults = DEFAULT_PHONE_PREFERENCES.settings
return {
settings: {
airplaneMode:
typeof settings.airplaneMode === 'boolean'
? settings.airplaneMode
: defaults.airplaneMode,
bluetooth:
typeof settings.bluetooth === 'boolean'
? settings.bluetooth
: defaults.bluetooth,
cellular:
typeof settings.cellular === 'boolean'
? settings.cellular
: defaults.cellular,
personalHotspot:
typeof settings.personalHotspot === 'boolean'
? settings.personalHotspot
: defaults.personalHotspot,
vpn: typeof settings.vpn === 'boolean' ? settings.vpn : defaults.vpn,
wallpaper: WALLPAPER_IDS.includes(settings.wallpaper as WallpaperId)
? (settings.wallpaper as WallpaperId)
: defaults.wallpaper,
wifi:
typeof settings.wifi === 'boolean' ? settings.wifi : defaults.wifi,
},
version: 1,
}
} catch {
return structuredClone(DEFAULT_PHONE_PREFERENCES)
}
}
export function readPhonePreferences(): PhonePreferencesV1 {
return parsePhonePreferences(
window.localStorage.getItem(PHONE_PREFERENCES_KEY),
)
}
export function writePhonePreferences(preferences: PhonePreferencesV1): void {
window.localStorage.setItem(
PHONE_PREFERENCES_KEY,
JSON.stringify(preferences),
)
}