ENH - clock app

This commit is contained in:
Eichenholz
2026-08-03 21:40:56 +02:00
parent e64bddcc15
commit c955e31e63
18 changed files with 1146 additions and 92 deletions
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { alarmMinuteKey, isAlarmDue, type Alarm } from './alarms'
const alarm: Alarm = {
enabled: true,
id: 'test',
lastTriggeredMinute: null,
note: '',
sound: 'radar',
time: '07:30',
weekdays: [1, 2, 3, 4, 5],
}
describe('alarm scheduling', () => {
it('fires once in the matching local minute and weekday', () => {
const monday = new Date(2026, 7, 3, 7, 30)
expect(isAlarmDue(alarm, monday)).toBe(true)
expect(
isAlarmDue(
{ ...alarm, lastTriggeredMinute: alarmMinuteKey(monday) },
monday,
),
).toBe(false)
expect(isAlarmDue(alarm, new Date(2026, 7, 2, 7, 30))).toBe(false)
})
it('treats an empty repeat selection as a one-time daily match', () => {
expect(
isAlarmDue(
{ ...alarm, time: '09:00', weekdays: [] },
new Date(2026, 7, 2, 9, 0),
),
).toBe(true)
})
})
+119
View File
@@ -0,0 +1,119 @@
export const ALARMS_STORAGE_KEY = 'sky_phone.clock.alarms.v1'
export const ALARM_SOUND_IDS = ['radar', 'beacon', 'chimes'] as const
export const WEEKDAY_IDS = [1, 2, 3, 4, 5, 6, 0] as const
export type AlarmSoundId = (typeof ALARM_SOUND_IDS)[number]
export type Alarm = {
enabled: boolean
id: string
lastTriggeredMinute: string | null
note: string
sound: AlarmSoundId
time: string
weekdays: number[]
}
export type AlarmDraft = Pick<Alarm, 'note' | 'sound' | 'time' | 'weekdays'>
const DEFAULT_ALARMS: Alarm[] = [
{
enabled: true,
id: 'weekday',
lastTriggeredMinute: null,
note: '',
sound: 'radar',
time: '07:30',
weekdays: [1, 2, 3, 4, 5],
},
{
enabled: false,
id: 'weekend',
lastTriggeredMinute: null,
note: '',
sound: 'chimes',
time: '09:00',
weekdays: [0, 6],
},
]
function isAlarmSound(value: unknown): value is AlarmSoundId {
return (
typeof value === 'string' &&
ALARM_SOUND_IDS.includes(value as AlarmSoundId)
)
}
function readAlarm(value: unknown): Alarm | null {
if (!value || typeof value !== 'object') return null
const alarm = value as Partial<Alarm>
if (
typeof alarm.id !== 'string' ||
typeof alarm.enabled !== 'boolean' ||
typeof alarm.time !== 'string' ||
!/^([01]\d|2[0-3]):[0-5]\d$/.test(alarm.time) ||
typeof alarm.note !== 'string' ||
!isAlarmSound(alarm.sound) ||
!Array.isArray(alarm.weekdays)
) {
return null
}
return {
enabled: alarm.enabled,
id: alarm.id,
lastTriggeredMinute:
typeof alarm.lastTriggeredMinute === 'string'
? alarm.lastTriggeredMinute
: null,
note: alarm.note.slice(0, 80),
sound: alarm.sound,
time: alarm.time,
weekdays: [
...new Set(
alarm.weekdays.filter(
(weekday): weekday is number =>
Number.isInteger(weekday) && weekday >= 0 && weekday <= 6,
),
),
],
}
}
export function readAlarms(): Alarm[] {
const raw = window.localStorage.getItem(ALARMS_STORAGE_KEY)
if (!raw) return structuredClone(DEFAULT_ALARMS)
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return structuredClone(DEFAULT_ALARMS)
return parsed.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm)
} catch {
return structuredClone(DEFAULT_ALARMS)
}
}
export function writeAlarms(alarms: Alarm[]): void {
window.localStorage.setItem(ALARMS_STORAGE_KEY, JSON.stringify(alarms))
}
export function alarmMinuteKey(date: Date): string {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
String(date.getHours()).padStart(2, '0'),
String(date.getMinutes()).padStart(2, '0'),
].join('-')
}
export function isAlarmDue(alarm: Alarm, date: Date): boolean {
if (!alarm.enabled) return false
const currentTime = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
if (alarm.time !== currentTime) return false
if (alarm.weekdays.length && !alarm.weekdays.includes(date.getDay())) {
return false
}
return alarm.lastTriggeredMinute !== alarmMinuteKey(date)
}
+4
View File
@@ -14,6 +14,7 @@ describe('preferences', () => {
settings: {
appearanceMode: 'light',
notificationVolume: 45,
notificationDurationSeconds: 14,
notifications: {
camera: { enabled: false, sounds: false },
},
@@ -24,6 +25,7 @@ describe('preferences', () => {
)
expect(value.settings.appearanceMode).toBe('light')
expect(value.settings.notificationVolume).toBe(45)
expect(value.settings.notificationDurationSeconds).toBe(14)
expect(value.settings.notifications.camera).toEqual({
enabled: false,
sounds: false,
@@ -41,6 +43,7 @@ describe('preferences', () => {
appearanceMode: 'neon',
frame: 'gold',
notificationVolume: -10,
notificationDurationSeconds: 100,
phoneScale: 500,
ringtoneVolume: 120,
},
@@ -50,6 +53,7 @@ describe('preferences', () => {
expect(value.settings.appearanceMode).toBe('automatic')
expect(value.settings.frame).toBe('black')
expect(value.settings.notificationVolume).toBe(0)
expect(value.settings.notificationDurationSeconds).toBe(30)
expect(value.settings.phoneScale).toBe(115)
expect(value.settings.ringtoneVolume).toBe(100)
})
+8
View File
@@ -30,6 +30,7 @@ export type PhonePreferencesV1 = {
appearanceMode: AppearanceMode
frame: PhoneFrameId
notificationSound: NotificationSoundId
notificationDurationSeconds: number
notificationVolume: number
notifications: Record<PhoneAppId, AppNotificationPreferences>
phoneScale: number
@@ -59,6 +60,7 @@ export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
appearanceMode: 'automatic',
frame: 'black',
notificationSound: 'chime',
notificationDurationSeconds: 10,
notificationVolume: 70,
notifications: DEFAULT_APP_NOTIFICATIONS,
phoneScale: 100,
@@ -147,6 +149,12 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
NOTIFICATION_SOUND_IDS,
defaults.notificationSound,
),
notificationDurationSeconds: readNumber(
settings.notificationDurationSeconds,
defaults.notificationDurationSeconds,
3,
30,
),
notificationVolume: readNumber(
settings.notificationVolume,
defaults.notificationVolume,
+79
View File
@@ -0,0 +1,79 @@
import type { AlarmSoundId } from '@/utils/alarms'
import type { NotificationSoundId } from '@/utils/preferences'
export type PhoneToneId = AlarmSoundId | NotificationSoundId
const TONE_PATTERNS: Record<PhoneToneId, number[]> = {
beacon: [660, 880, 660, 880],
chime: [784, 1047],
chimes: [523, 659, 784, 1047],
radar: [880, 0, 880, 0, 1175],
signal: [740, 988, 740],
soft: [523, 659],
}
export function playPhoneTone(
tone: PhoneToneId,
volumePercent: number,
loop: boolean,
): () => void {
const AudioContextConstructor =
window.AudioContext ??
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext
if (!AudioContextConstructor) {
console.error('[Phone audio] Web Audio is unavailable')
return () => undefined
}
const context = new AudioContextConstructor()
const frequencies = TONE_PATTERNS[tone]
const volume = Math.max(0, Math.min(1, volumePercent / 100)) * 0.16
let stopped = false
let nextPatternTimer: ReturnType<typeof setTimeout> | undefined
let oscillators: OscillatorNode[] = []
const schedulePattern = (): void => {
if (stopped) return
const startAt = context.currentTime + 0.02
oscillators = frequencies.flatMap((frequency, index) => {
if (!frequency) return []
const oscillator = context.createOscillator()
const gain = context.createGain()
const toneStart = startAt + index * 0.24
oscillator.type = 'sine'
oscillator.frequency.value = frequency
gain.gain.setValueAtTime(0, toneStart)
gain.gain.linearRampToValueAtTime(volume, toneStart + 0.025)
gain.gain.exponentialRampToValueAtTime(0.001, toneStart + 0.19)
oscillator.connect(gain).connect(context.destination)
oscillator.start(toneStart)
oscillator.stop(toneStart + 0.2)
return [oscillator]
})
if (loop) {
nextPatternTimer = setTimeout(
schedulePattern,
frequencies.length * 240 + 520,
)
}
}
void context.resume().then(schedulePattern).catch((error: unknown) => {
console.error('[Phone audio] Failed to start tone', error)
})
return () => {
stopped = true
if (nextPatternTimer) clearTimeout(nextPatternTimer)
for (const oscillator of oscillators) {
try {
oscillator.stop()
} catch {
// The oscillator already completed its scheduled note.
}
}
void context.close()
}
}