feat: add Pomodoro session celebration with trophy animation
This commit is contained in:
parent
a1120a56e8
commit
35f53e87f5
@ -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() {
|
||||
|
||||
@ -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 · ~{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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user