diff --git a/README.md b/README.md index bf0436a..a1cc698 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Runs as a Windows desktop app, a web app or a browser extension, and keeps every - **Focus timer** with pause, resume, breaks, extend-on-complete and goal history (press ↑/↓ in the goal field). - **Notifications** when a focus session or break runs out. The app asks for permission with Chrome's own prompt the first time you open it; **Menu → Settings → Notifications** shows the current state and asks again if you dismissed the prompt. If nothing appears, allow notifications for your browser in your system settings and turn off Do Not Disturb / Focus. -- **Sound themes**: Classic (default), Glass, Wood, Singing bowl, Soft or Silent, each covering start, finish, break, back to work, pause, resume and button taps. Preview and pick them in **Menu → Settings → Sounds**. All sounds are synthesized in the browser. +- **Sound themes**: Classic (default), Glass, Wood, Singing bowl, Soft, Aurora or Silent, each covering start, finish, break, back to work, pause, resume, the coffee refill and button taps. Preview and pick them in **Menu → Settings → Sounds**. All sounds are synthesized in the browser. - **Pixel-art progress animations** — a coffee cup that fills, a battery that charges, a rocket that lifts off — plus an auto-shuffle option. - **To-dos** with time estimates, notes, drag-and-drop ordering, a backlog and archive; start a focus session straight from a to-do. - **Habits** with 7- and 30-day grids and notes per day. diff --git a/src/components/focus/PixelArt.tsx b/src/components/focus/PixelArt.tsx index 6d87c41..6d87380 100644 --- a/src/components/focus/PixelArt.tsx +++ b/src/components/focus/PixelArt.tsx @@ -1,5 +1,7 @@ -import { useEffect, type ReactNode } from "react"; -import { BATTERY_LEVELS, metaOf, type AnimationType } from "./animations"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { BATTERY_LEVELS, coffeeFill, isRefill, metaOf, type AnimationType } from "./animations"; +import { playRefill } from "./coffeeRefill"; +import { playSound } from "./sounds"; type Pixel = readonly [x: number, y: number, width: number, height: number, color: string]; @@ -123,6 +125,46 @@ function preloadCoffeeFrames(frames: number): void { for (let frame = 1; frame <= frames; frame++) new Image().src = coffeeFrameUrl(frame); } +interface Refill { + readonly from: number; + readonly to: number; +} + +const prefersReducedMotion = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +/** Shows the coffee frame; when the cup jumps to a fuller level it fizzes up to it on a canvas over the empty cup. */ +function CoffeeCup({ frame, frames, className, label }: { frame: number; frames: number; className: string; label: string }) { + const canvasRef = useRef(null); + const [lastFrame, setLastFrame] = useState(frame); + const [refill, setRefill] = useState(null); + if (frame !== lastFrame) { + setLastFrame(frame); + if (isRefill(lastFrame, frame) && !prefersReducedMotion()) setRefill({ from: lastFrame, to: frame }); + } + + useEffect(() => { + const canvas = canvasRef.current; + if (!refill || !canvas) return; + playSound("refill"); + return playRefill(canvas, { + from: coffeeFill(refill.from, frames), + to: coffeeFill(refill.to, frames), + fullSrc: coffeeFrameUrl(1), + emptySrc: coffeeFrameUrl(frames), + toSrc: coffeeFrameUrl(refill.to), + onDone: () => setRefill(null), + }); + }, [refill, frames]); + + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- frame-by-frame animation; next/image would re-optimize every frame */} + {label} + {refill ?
+ ); +} + /** Draws `frame` (1-based) of a pixel-art animation on a 32×32 grid. */ export function PixelArt({ type, frame, className, label }: { type: AnimationType; frame: number; className: string; label: string }) { const { frames } = metaOf(type); @@ -130,10 +172,7 @@ export function PixelArt({ type, frame, className, label }: { type: AnimationTyp useEffect(() => { if (type === "coffee-cup") preloadCoffeeFrames(frames); }, [type, frames]); - if (type === "coffee-cup") { - // eslint-disable-next-line @next/next/no-img-element -- frame-by-frame animation; next/image would re-optimize every frame - return {label}; - } + if (type === "coffee-cup") return ; const { pixels, flame: flamePixels } = scene(type, clamped - 1, frames); return ( diff --git a/src/components/focus/animations.ts b/src/components/focus/animations.ts index 1b93f6a..792e4a7 100644 --- a/src/components/focus/animations.ts +++ b/src/components/focus/animations.ts @@ -71,3 +71,9 @@ export function currentFrame(type: AnimationType, state: FrameState): number { if (type === "rocket" && state.isOnBreak && phaseOf(state) !== "running") return 1; return fillingFrame(state, frames); } + +/** How full the coffee cup is at `frame` (1 = full cup, `frames` = empty). */ +export const coffeeFill = (frame: number, frames: number): number => (frames - frame) / (frames - 1); + +/** A jump of more than one frame toward a fuller cup, e.g. extending or resetting, as opposed to the slow refill during a break. */ +export const isRefill = (from: number, to: number): boolean => from - to > 1; diff --git a/src/components/focus/coffeeRefill.ts b/src/components/focus/coffeeRefill.ts new file mode 100644 index 0000000..e8277e6 --- /dev/null +++ b/src/components/focus/coffeeRefill.ts @@ -0,0 +1,278 @@ +/* + * The fizzing refill drawn over the coffee-cup frames when the cup jumps to a fuller level. + * Coordinates are in the frames' own 576×576 pixels; the liquid shape comes from the full and empty frames. + */ + +export const REFILL_MS = 2600; +const SETTLE_MS = 700; +const HANDOFF_MS = 350; + +const SIZE = 576; +const LEFT = 36; +const RIGHT = 448; +const TOP_FULL = 90; +const BOTTOM = 538; +const BUBBLES_PER_SECOND = 110; +const MAX_RESTING = 70; + +interface Bubble { + x: number; + y: number; + r: number; + r0: number; + vy: number; + age: number; + wobble: number; + resting: boolean; + life: number; + drift: number; +} + +interface Ring { + x: number; + y: number; + r: number; + age: number; +} + +const rand = (min: number, max: number) => min + Math.random() * (max - min); +const clamp01 = (value: number) => Math.min(1, Math.max(0, value)); +const smooth = (t: number) => t * t * (3 - 2 * t); +const levelY = (fill: number) => BOTTOM - (BOTTOM - TOP_FULL) * fill; + +const loadImage = (src: string) => + new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = reject; + image.src = src; + }); + +let maskPromise: Promise | null = null; + +/** Pixels that differ between the full and empty frames are where coffee can be. */ +function liquidMask(fullSrc: string, emptySrc: string): Promise { + maskPromise ??= Promise.all([loadImage(fullSrc), loadImage(emptySrc)]).then(([full, empty]) => { + const pixels = (image: HTMLImageElement) => { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = SIZE; + const context = canvas.getContext("2d")!; + context.drawImage(image, 0, 0); + return context.getImageData(0, 0, SIZE, SIZE).data; + }; + const a = pixels(full); + const b = pixels(empty); + const mask = new ImageData(SIZE, SIZE); + for (let i = 0; i < a.length; i += 4) { + const diff = Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]) + Math.abs(a[i + 3] - b[i + 3]); + if (diff > 40) mask.data[i + 3] = 255; + } + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = SIZE; + canvas.getContext("2d")!.putImageData(mask, 0, 0); + return canvas; + }); + maskPromise.catch(() => (maskPromise = null)); + return maskPromise; +} + +function drawBubble(context: CanvasRenderingContext2D, bubble: Bubble): void { + const { r } = bubble; + // Large bubbles flex as they rise; resting ones flatten into small domes. + const squash = bubble.resting ? 0.82 : r > 5 ? 1 + Math.sin(bubble.age * 14 + bubble.wobble) * 0.08 : 1; + context.save(); + context.translate(bubble.x, bubble.y); + context.scale(1 / squash, squash); + const body = context.createRadialGradient(-r * 0.2, -r * 0.25, r * 0.1, 0, 0, r); + body.addColorStop(0, "rgba(255,236,214,.04)"); + body.addColorStop(0.7, "rgba(255,232,206,.14)"); + body.addColorStop(1, "rgba(255,240,224,.5)"); + context.beginPath(); + context.arc(0, 0, r, 0, Math.PI * 2); + context.fillStyle = body; + context.fill(); + context.lineWidth = Math.max(0.8, r * 0.12); + context.strokeStyle = bubble.resting ? "rgba(255,246,236,.75)" : "rgba(255,246,236,.55)"; + context.stroke(); + context.beginPath(); + context.ellipse(-r * 0.38, -r * 0.4, r * 0.26, r * 0.17, -0.6, 0, Math.PI * 2); + context.fillStyle = "rgba(255,255,255,.92)"; + context.fill(); + context.restore(); +} + +/** + * Animates the cup from `from` to `to` (fill fractions, 0 = empty) on `canvas`, which sits over the empty-cup frame. + * Ends by fading in `toSrc` so the hand-back to the plain frame is seamless. Returns a cancel function. + */ +export function playRefill( + canvas: HTMLCanvasElement, + { from, to, fullSrc, emptySrc, toSrc, onDone }: { from: number; to: number; fullSrc: string; emptySrc: string; toSrc: string; onDone: () => void }, +): () => void { + let cancelled = false; + let frameId = 0; + const context = canvas.getContext("2d"); + const layer = document.createElement("canvas"); + layer.width = layer.height = canvas.width = canvas.height = SIZE; + const liquid = layer.getContext("2d"); + if (!context || !liquid) { + onDone(); + return () => {}; + } + + const bubbles: Bubble[] = []; + const rings: Ring[] = []; + let fill = from; + let elapsed = 0; + + const wave = (x: number) => { + const amplitude = 7 * (elapsed < REFILL_MS ? 1 : Math.exp(-((elapsed - REFILL_MS) / 1000) * 4)); + const t = elapsed / 1000; + return amplitude * Math.min(1, fill * 5) * (Math.sin(x * 0.028 + t * 7) * 0.6 + Math.sin(x * 0.07 - t * 10) * 0.4); + }; + const surface = (x: number) => levelY(fill) + wave(x); + + const pop = (bubble: Bubble, top: number) => { + bubble.life = -1; + rings.push({ x: bubble.x, y: top, r: bubble.r, age: 0 }); + }; + + function update(dt: number) { + elapsed += dt * 1000; + fill = from + (to - from) * smooth(clamp01(elapsed / REFILL_MS)); + if (elapsed < REFILL_MS) { + for (let n = BUBBLES_PER_SECOND * dt; n > 0 && (n >= 1 || Math.random() < n); n--) { + const r = rand(1.6, 5.5); + bubbles.push({ x: rand(LEFT + 30, RIGHT - 30), y: Math.max(levelY(fill) + 12, BOTTOM - rand(20, 60)), r, r0: r, vy: -rand(200, 420), age: 0, wobble: rand(0, 6), resting: false, life: 0, drift: 0 }); + } + } + let resting = 0; + for (const bubble of bubbles) { + bubble.age += dt; + const top = surface(bubble.x); + if (bubble.resting) { + // Bubbles that reach the top rest there, drift toward the cup wall, then burst. + resting++; + bubble.x = Math.max(LEFT + 10 + bubble.r, Math.min(RIGHT - 10 - bubble.r, bubble.x + bubble.drift * dt)); + bubble.y = top - bubble.r * 0.35; + bubble.life -= dt; + if (bubble.life <= 0) pop(bubble, top); + continue; + } + // Rising air expands as the pressure drops; bigger bubbles rise faster and zig-zag more. + bubble.r = bubble.r0 * (1 + 0.3 * clamp01((BOTTOM - bubble.y) / 420)); + bubble.vy += (-(70 + 40 * bubble.r) - bubble.vy) * Math.min(1, dt * 3); + bubble.x = Math.max(LEFT + 14, Math.min(RIGHT - 14, bubble.x + Math.cos(bubble.age * (7 + bubble.r0) + bubble.wobble) * bubble.r * 9 * dt)); + bubble.y += bubble.vy * dt; + if (bubble.y - bubble.r * 0.5 <= top) { + if (bubble.r > 1.8 && Math.random() < 0.75) { + bubble.resting = true; + bubble.life = rand(0.25, 0.5) + bubble.r * 0.09; + bubble.drift = (bubble.x < (LEFT + RIGHT) / 2 ? -1 : 1) * rand(15, 55); + } else pop(bubble, top); + } + } + if (resting > MAX_RESTING) { + bubbles + .filter((bubble) => bubble.resting && bubble.life > 0) + .sort((a, b) => a.life - b.life) + .slice(0, resting - MAX_RESTING) + .forEach((bubble) => pop(bubble, bubble.y)); + } + for (let i = bubbles.length - 1; i >= 0; i--) if (bubbles[i].life < 0) bubbles.splice(i, 1); + for (const ring of rings) ring.age += dt; + for (let i = rings.length - 1; i >= 0; i--) if (rings[i].age > 0.32) rings.splice(i, 1); + } + + function draw(mask: HTMLCanvasElement, target: HTMLImageElement) { + context!.clearRect(0, 0, SIZE, SIZE); + if (fill > 0.002) { + const top = levelY(fill); + const points: [number, number][] = []; + for (let x = LEFT - 6; x <= RIGHT + 6; x += 6) points.push([x, surface(x)]); + liquid!.globalCompositeOperation = "source-over"; + liquid!.clearRect(0, 0, SIZE, SIZE); + liquid!.beginPath(); + liquid!.moveTo(LEFT - 6, SIZE); + for (const [x, y] of points) liquid!.lineTo(x, y); + liquid!.lineTo(RIGHT + 6, SIZE); + liquid!.closePath(); + const depth = liquid!.createLinearGradient(0, top, 0, BOTTOM); + depth.addColorStop(0, "rgb(162,124,97)"); + depth.addColorStop(0.25, "rgb(150,114,89)"); + depth.addColorStop(1, "rgb(126,93,71)"); + liquid!.fillStyle = depth; + liquid!.fill(); + // Light catches the top of the coffee, and it climbs a little where it meets the cup wall. + const sheen = liquid!.createLinearGradient(0, top - 6, 0, top + 46); + sheen.addColorStop(0, "rgba(232,200,165,.38)"); + sheen.addColorStop(1, "rgba(232,200,165,0)"); + liquid!.globalCompositeOperation = "source-atop"; + liquid!.fillStyle = sheen; + liquid!.fillRect(LEFT - 6, top - 20, RIGHT - LEFT + 12, 70); + liquid!.globalCompositeOperation = "source-over"; + for (const [x, dir] of [[LEFT + 3, 1], [RIGHT - 3, -1]] as const) { + const y = surface(x); + const meniscus = liquid!.createLinearGradient(x, 0, x + dir * 22, 0); + meniscus.addColorStop(0, "rgba(228,194,156,.75)"); + meniscus.addColorStop(1, "rgba(228,194,156,0)"); + liquid!.beginPath(); + liquid!.moveTo(x, y - 7); + liquid!.quadraticCurveTo(x + dir * 6, y, x + dir * 22, y + 2); + liquid!.lineTo(x + dir * 22, y + 10); + liquid!.lineTo(x, y + 10); + liquid!.closePath(); + liquid!.fillStyle = meniscus; + liquid!.fill(); + } + liquid!.beginPath(); + points.forEach(([x, y], i) => (i ? liquid!.lineTo(x, y + 2) : liquid!.moveTo(x, y + 2))); + liquid!.lineWidth = 3; + liquid!.strokeStyle = "rgba(236,204,168,.5)"; + liquid!.stroke(); + for (const bubble of bubbles) if (!bubble.resting) drawBubble(liquid!, bubble); + for (const bubble of bubbles) if (bubble.resting) drawBubble(liquid!, bubble); + liquid!.globalCompositeOperation = "destination-in"; + liquid!.drawImage(mask, 0, 0); + context!.drawImage(layer, 0, 0); + } + for (const ring of rings) { + const k = ring.age / 0.32; + context!.beginPath(); + context!.ellipse(ring.x, ring.y, ring.r * (1 + k * 1.6), ring.r * (0.4 + k * 0.5), 0, 0, Math.PI * 2); + context!.lineWidth = 2; + context!.strokeStyle = `rgba(255,240,225,${0.8 * (1 - k)})`; + context!.stroke(); + } + const handoff = clamp01((elapsed - REFILL_MS - SETTLE_MS) / HANDOFF_MS); + if (handoff > 0) { + context!.globalAlpha = handoff; + context!.imageSmoothingEnabled = false; + context!.drawImage(target, 0, 0, SIZE, SIZE); + context!.globalAlpha = 1; + } + } + + Promise.all([liquidMask(fullSrc, emptySrc), loadImage(toSrc)]) + .then(([mask, target]) => { + let last = performance.now(); + const tick = (now: number) => { + if (cancelled) return; + update(Math.min(0.05, (now - last) / 1000)); + last = now; + draw(mask, target); + if (elapsed >= REFILL_MS + SETTLE_MS + HANDOFF_MS) return onDone(); + frameId = requestAnimationFrame(tick); + }; + frameId = requestAnimationFrame(tick); + }) + .catch((error: unknown) => { + console.error("Failed to play the coffee refill:", error); + if (!cancelled) onDone(); + }); + + return () => { + cancelled = true; + cancelAnimationFrame(frameId); + }; +} diff --git a/src/components/focus/sounds.ts b/src/components/focus/sounds.ts index 7c61657..18bd286 100644 --- a/src/components/focus/sounds.ts +++ b/src/components/focus/sounds.ts @@ -4,7 +4,7 @@ * except where a theme deliberately stays quiet (null). */ -export type SoundEvent = "tap" | "focusStart" | "focusEnd" | "breakStart" | "breakEnd" | "pause" | "resume"; +export type SoundEvent = "tap" | "focusStart" | "focusEnd" | "breakStart" | "breakEnd" | "pause" | "resume" | "refill"; interface Overtone { /** Frequency multiple of the note. */ @@ -25,10 +25,12 @@ interface Note { readonly volume: number; readonly wave?: OscillatorType; readonly attack?: number; + /** Frequency multiple reached by the end of the note; above 1 sweeps up like a popping bubble. */ + readonly glide?: number; readonly overtones?: readonly Overtone[]; } -export type SoundThemeId = "classic" | "glass" | "wood" | "bowl" | "soft" | "silent"; +export type SoundThemeId = "classic" | "glass" | "wood" | "bowl" | "soft" | "aurora" | "silent"; export interface SoundTheme { readonly id: SoundThemeId; @@ -49,6 +51,11 @@ const BOWL: readonly Overtone[] = [ { ratio: 2.76, volume: 0.35, length: 0.6 }, { ratio: 5.4, volume: 0.12, length: 0.3 }, ]; +// A fifth and an octave above, fading with the note, give a soft airy pad. +const AIR: readonly Overtone[] = [ + { ratio: 1.5, volume: 0.3, length: 1 }, + { ratio: 2, volume: 0.2, length: 0.8 }, +]; const note = (frequency: number, delay: number, duration: number, volume: number, extra: Partial = {}): Note => ({ frequency, @@ -62,8 +69,21 @@ const mallet = (frequency: number, delay: number, duration = 0.45, volume = 0.13 note(frequency, delay, duration, volume, { wave: "triangle", overtones: MALLET }); const strike = (frequency: number, delay: number, duration = 3.5, volume = 0.12) => note(frequency, delay, duration, volume, { attack: 0.02, overtones: BOWL }); +const pad = (frequency: number, delay: number, duration = 1.6, volume = 0.05) => + note(frequency, delay, duration, volume, { attack: 0.12, overtones: AIR }); const blip = (frequency: number, delay: number, duration = 0.2, volume = 0.05) => note(frequency, delay, duration, volume); +// Matches the coffee refill animation, which fills the cup in about 2.6 seconds. +const REFILL_SECONDS = 2.5; +/** `count` bubble pops spread over the refill, rising in pitch as the cup fills, then `finish` when it is full. */ +const fizz = (base: number, count: number, volume: number, extra: Partial = {}): Note[] => + Array.from({ length: count }, (_, index) => + note(base * (1 + ((index * 5) % 7) * 0.06) * (1 + (0.5 * index) / count), (REFILL_SECONDS * (index + ((index * 7) % 3) / 3)) / count, 0.08, volume, { + glide: 2, + ...extra, + }), + ); + const CLASSIC_CHIME = [note(659.25, 0, 0.35, 0.12), note(880, 0.12, 0.5, 0.12)]; const CLASSIC_BELL = [ note(783.99, 0, 1.2, 0.14, { overtones: BELL }), @@ -84,6 +104,7 @@ export const SOUND_THEMES: readonly SoundTheme[] = [ breakEnd: CLASSIC_BELL, pause: null, resume: CLASSIC_CHIME, + refill: [...fizz(520, 14, 0.04), note(659.25, REFILL_SECONDS, 0.35, 0.1), note(880, REFILL_SECONDS + 0.12, 0.5, 0.1)], }, }, { @@ -98,6 +119,7 @@ export const SOUND_THEMES: readonly SoundTheme[] = [ breakEnd: [ping(987.77, 0, 0.9), ping(1318.51, 0.16, 1), ping(1760, 0.32, 1.4)], pause: [ping(987.77, 0, 0.35, 0.05)], resume: [ping(1318.51, 0, 0.35, 0.05)], + refill: [...fizz(1100, 16, 0.025), ping(1760, REFILL_SECONDS, 1.2)], }, }, { @@ -112,6 +134,7 @@ export const SOUND_THEMES: readonly SoundTheme[] = [ breakEnd: [mallet(523.25, 0), mallet(659.25, 0.08), mallet(783.99, 0.16), mallet(1046.5, 0.24, 0.7)], pause: [mallet(392, 0, 0.3, 0.07)], resume: [mallet(523.25, 0, 0.3, 0.07)], + refill: [...fizz(700, 12, 0.04, { wave: "triangle" }), mallet(783.99, REFILL_SECONDS, 0.6)], }, }, { @@ -126,6 +149,7 @@ export const SOUND_THEMES: readonly SoundTheme[] = [ breakEnd: [strike(220, 0, 2.5), strike(293.66, 0.9)], pause: null, resume: null, + refill: [...fizz(300, 8, 0.03), strike(261.63, REFILL_SECONDS, 3)], }, }, { @@ -140,13 +164,29 @@ export const SOUND_THEMES: readonly SoundTheme[] = [ breakEnd: [blip(523.25, 0), blip(659.25, 0.18), blip(880, 0.36)], pause: [blip(523.25, 0, 0.1, 0.03)], resume: [blip(659.25, 0, 0.1, 0.03)], + refill: [...fizz(600, 10, 0.02), blip(880, REFILL_SECONDS)], + }, + }, + { + id: "aurora", + name: "Aurora", + description: "Airy chords that bloom slowly. Calm for long sessions; quiet for taps.", + sounds: { + tap: null, + focusStart: [pad(523.25, 0, 1.4), pad(659.25, 0.05, 1.4)], + focusEnd: [pad(523.25, 0, 2.6), pad(659.25, 0.2, 2.6), pad(783.99, 0.4, 2.6), pad(987.77, 0.6, 2.8, 0.035)], + breakStart: [pad(440, 0, 1.8), pad(554.37, 0.15, 1.8)], + breakEnd: [pad(587.33, 0, 1.6), pad(739.99, 0.15, 1.8), pad(880, 0.3, 2)], + pause: [pad(392, 0, 0.8, 0.035)], + resume: [pad(523.25, 0, 0.8, 0.035)], + refill: [...fizz(400, 10, 0.025), pad(659.25, REFILL_SECONDS - 0.3, 1.8, 0.04), pad(987.77, REFILL_SECONDS - 0.1, 1.8, 0.03)], }, }, { id: "silent", name: "Silent", description: "No sounds at all. Notifications still appear when they're on.", - sounds: { tap: null, focusStart: null, focusEnd: null, breakStart: null, breakEnd: null, pause: null, resume: null }, + sounds: { tap: null, focusStart: null, focusEnd: null, breakStart: null, breakEnd: null, pause: null, resume: null, refill: null }, }, ]; @@ -185,7 +225,8 @@ function playTone(audio: AudioContext, tone: Note): void { const oscillator = audio.createOscillator(); const gain = audio.createGain(); oscillator.type = tone.wave ?? "sine"; - oscillator.frequency.value = tone.frequency; + oscillator.frequency.setValueAtTime(tone.frequency, startAt); + if (tone.glide) oscillator.frequency.exponentialRampToValueAtTime(tone.frequency * tone.glide, startAt + tone.duration); gain.gain.setValueAtTime(SILENCE, startAt); gain.gain.exponentialRampToValueAtTime(tone.volume, startAt + (tone.attack ?? ATTACK_SECONDS)); gain.gain.exponentialRampToValueAtTime(SILENCE, startAt + tone.duration); diff --git a/src/components/shell/SoundsPanel.tsx b/src/components/shell/SoundsPanel.tsx index 5708264..cca231d 100644 --- a/src/components/shell/SoundsPanel.tsx +++ b/src/components/shell/SoundsPanel.tsx @@ -20,6 +20,7 @@ const MOMENTS: readonly { event: SoundEvent; label: string }[] = [ { event: "pause", label: "Pause" }, { event: "resume", label: "Resume" }, { event: "tap", label: "Tap" }, + { event: "refill", label: "Refill" }, ]; const ownSound = { [OWN_SOUND_ATTRIBUTE]: "" }; diff --git a/tests/animations.test.ts b/tests/animations.test.ts new file mode 100644 index 0000000..af6dc49 --- /dev/null +++ b/tests/animations.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { coffeeFill, currentFrame, isRefill, type FrameState } from "../src/components/focus/animations.ts"; + +const COMPLETED: FrameState = { isOnBreak: false, hasStartedBreak: false, hasStarted: true, showDirectBreak: false, isCompleted: true, totalSeconds: 1500, currentSeconds: 0 }; + +test("extending a finished 25-minute session by 10 minutes refills the cup to 10/25", () => { + const empty = currentFrame("coffee-cup", COMPLETED); + const extended = currentFrame("coffee-cup", { ...COMPLETED, isCompleted: false, currentSeconds: 600 }); + assert.equal(empty, 26); + assert.equal(coffeeFill(extended, 26), 0.4); + assert.ok(isRefill(empty, extended)); +}); + +test("the slow refill during a break and the drain during focus are not refills", () => { + assert.equal(isRefill(20, 19), false); + assert.equal(isRefill(5, 6), false); + assert.equal(isRefill(26, 26), false); +}); + +test("coffeeFill maps the first frame to a full cup and the last to an empty one", () => { + assert.equal(coffeeFill(1, 26), 1); + assert.equal(coffeeFill(26, 26), 0); + assert.equal(coffeeFill(13.5, 26), 0.5); +}); diff --git a/tests/sounds.test.ts b/tests/sounds.test.ts new file mode 100644 index 0000000..e58d749 --- /dev/null +++ b/tests/sounds.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { SOUND_THEMES } from "../src/components/focus/sounds.ts"; + +test("every theme except Silent has a refill sound that stays quiet", () => { + for (const theme of SOUND_THEMES) { + const refill = theme.sounds.refill; + if (theme.id === "silent") { + assert.equal(refill, null); + continue; + } + assert.ok(refill && refill.length > 0, `${theme.id} has no refill sound`); + for (const tone of refill) assert.ok(tone.volume <= 0.15, `${theme.id} refill note is too loud`); + } +});