feat: add Pomodoro session celebration with trophy animation

This commit is contained in:
saravanakumardb1 2026-02-27 21:23:45 -08:00
parent a1120a56e8
commit 35f53e87f5
2 changed files with 67 additions and 10 deletions

View File

@ -52,7 +52,7 @@ final class CascadeTests: XCTestCase {
func testWarningsInPastAreFired() {
let now = Date()
let targetTime = now.addingTimeInterval(600) // 10 min from now
let targetTime = now.addingTimeInterval(1800) // 30 min from now
let intervals = [120, 60, 15, 5] // 2h and 1h are in the past
let warnings = calculateCascadeWarnings(targetTime: targetTime, intervals: intervals, now: now)
@ -60,8 +60,8 @@ final class CascadeTests: XCTestCase {
// 120min and 60min warnings should be marked as fired (they're in the past)
let fired = warnings.filter(\.fired)
XCTAssertEqual(fired.count, 2)
XCTAssertTrue(warnings[0].fired) // 120m
XCTAssertTrue(warnings[1].fired) // 60m
XCTAssertTrue(warnings[0].fired) // 120m in the past
XCTAssertTrue(warnings[1].fired) // 60m in the past
XCTAssertFalse(warnings[2].fired) // 15m still in the future
XCTAssertFalse(warnings[3].fired) // 5m still in the future
}
@ -96,7 +96,7 @@ final class CascadeTests: XCTestCase {
func testGetNextWarning() {
let now = Date()
let targetTime = now.addingTimeInterval(3600)
let targetTime = now.addingTimeInterval(7200) // 2h from now
let warnings = calculateCascadeWarnings(
targetTime: targetTime,
intervals: [60, 30, 15, 5],
@ -105,6 +105,7 @@ final class CascadeTests: XCTestCase {
let next = getNextWarning(warnings)
XCTAssertNotNil(next)
// All warnings are in the future (earliest at targetTime - 60m = 1h from now)
XCTAssertEqual(next?.minutesBefore, 60)
}
@ -127,19 +128,24 @@ final class CascadeTests: XCTestCase {
func testCheckWarnings() {
let now = Date()
let targetTime = now.addingTimeInterval(3600)
let targetTime = now.addingTimeInterval(7200) // 2h from now
var warnings = calculateCascadeWarnings(
targetTime: targetTime,
intervals: [60, 30, 15, 5],
now: now
)
// Check at a time when 60m warning should fire (targetTime - 60min = now)
let checkTime = targetTime.addingTimeInterval(-3600) // exactly at 60m warning
let fired = checkWarnings(&warnings, now: checkTime.addingTimeInterval(1)) // 1 second after
// All warnings should be unfired initially
XCTAssertTrue(warnings.allSatisfy { !$0.fired })
// Check at a time when 60m warning should fire (targetTime - 60min)
let checkTime = targetTime.addingTimeInterval(-3599) // 1 second after 60m mark
let fired = checkWarnings(&warnings, now: checkTime)
// The 60m warning should fire
XCTAssertTrue(fired.count >= 1)
XCTAssertEqual(fired.count, 1)
XCTAssertTrue(warnings[0].fired) // 60m warning fired
XCTAssertFalse(warnings[1].fired) // 30m warning not yet
}
func testCheckWarningsNoneFire() {

View File

@ -5,7 +5,8 @@ import { getRemainingMs } from '@/lib/timer-engine';
import type { Timer } from '@/lib/timer-engine';
import { CountdownRing } from './CountdownRing';
import { formatDuration } from '@/lib/format';
import { Coffee, Pause, Play, X, SkipForward } from 'lucide-react';
import { Coffee, Pause, Play, X, SkipForward, Trophy } from 'lucide-react';
import { showToast } from './Toast';
interface PomodoroViewProps {
timer: Timer;
@ -26,12 +27,62 @@ export function PomodoroView({ timer }: PomodoroViewProps) {
const isBreak = pomState.isBreak || pomState.isLongBreak;
const isPaused = timer.state === 'paused';
const isFiring = timer.state === 'firing';
const isCompleted = timer.state === 'completed';
const roundLabel = isBreak
? pomState.isLongBreak ? 'Long Break' : 'Break'
: `Round ${pomState.currentRound} of ${config.rounds}`;
const ringColor = isBreak ? 'var(--cm-accent-secondary)' : 'var(--cm-accent)';
// Session complete celebration
if (isCompleted) {
const totalMinutes = config.workMinutes * config.rounds + config.breakMinutes * (config.rounds - 1) + config.longBreakMinutes;
return (
<div
className="rounded-2xl border p-8 text-center"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
background: 'linear-gradient(135deg, var(--cm-surface-card) 0%, rgba(52,211,153,0.08) 100%)',
}}
>
<div className="flex justify-center mb-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ backgroundColor: 'rgba(52,211,153,0.15)' }}
>
<Trophy size={32} style={{ color: 'var(--cm-success)' }} />
</div>
</div>
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--cm-success)' }}>
Session Complete!
</h3>
<p className="text-sm mb-1" style={{ color: 'var(--cm-text-primary)' }}>
{timer.label}
</p>
<p className="text-xs mb-4" style={{ color: 'var(--cm-text-tertiary)' }}>
{config.rounds} rounds &middot; ~{totalMinutes} minutes of focused work
</p>
<div className="flex justify-center gap-1.5 mb-4">
{Array.from({ length: config.rounds }).map((_, i) => (
<div
key={i}
className="w-3 h-3 rounded-full"
style={{ backgroundColor: 'var(--cm-success)' }}
/>
))}
</div>
<button
onClick={() => dismiss(timer.id)}
className="px-6 py-2.5 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
Close
</button>
</div>
);
}
return (
<div
className="rounded-2xl border p-6 text-center"