mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ENH - clock app
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user