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() {
|
func testWarningsInPastAreFired() {
|
||||||
let now = Date()
|
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 intervals = [120, 60, 15, 5] // 2h and 1h are in the past
|
||||||
|
|
||||||
let warnings = calculateCascadeWarnings(targetTime: targetTime, intervals: intervals, now: now)
|
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)
|
// 120min and 60min warnings should be marked as fired (they're in the past)
|
||||||
let fired = warnings.filter(\.fired)
|
let fired = warnings.filter(\.fired)
|
||||||
XCTAssertEqual(fired.count, 2)
|
XCTAssertEqual(fired.count, 2)
|
||||||
XCTAssertTrue(warnings[0].fired) // 120m
|
XCTAssertTrue(warnings[0].fired) // 120m — in the past
|
||||||
XCTAssertTrue(warnings[1].fired) // 60m
|
XCTAssertTrue(warnings[1].fired) // 60m — in the past
|
||||||
XCTAssertFalse(warnings[2].fired) // 15m — still in the future
|
XCTAssertFalse(warnings[2].fired) // 15m — still in the future
|
||||||
XCTAssertFalse(warnings[3].fired) // 5m — still in the future
|
XCTAssertFalse(warnings[3].fired) // 5m — still in the future
|
||||||
}
|
}
|
||||||
@ -96,7 +96,7 @@ final class CascadeTests: XCTestCase {
|
|||||||
|
|
||||||
func testGetNextWarning() {
|
func testGetNextWarning() {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let targetTime = now.addingTimeInterval(3600)
|
let targetTime = now.addingTimeInterval(7200) // 2h from now
|
||||||
let warnings = calculateCascadeWarnings(
|
let warnings = calculateCascadeWarnings(
|
||||||
targetTime: targetTime,
|
targetTime: targetTime,
|
||||||
intervals: [60, 30, 15, 5],
|
intervals: [60, 30, 15, 5],
|
||||||
@ -105,6 +105,7 @@ final class CascadeTests: XCTestCase {
|
|||||||
|
|
||||||
let next = getNextWarning(warnings)
|
let next = getNextWarning(warnings)
|
||||||
XCTAssertNotNil(next)
|
XCTAssertNotNil(next)
|
||||||
|
// All warnings are in the future (earliest at targetTime - 60m = 1h from now)
|
||||||
XCTAssertEqual(next?.minutesBefore, 60)
|
XCTAssertEqual(next?.minutesBefore, 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,19 +128,24 @@ final class CascadeTests: XCTestCase {
|
|||||||
|
|
||||||
func testCheckWarnings() {
|
func testCheckWarnings() {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let targetTime = now.addingTimeInterval(3600)
|
let targetTime = now.addingTimeInterval(7200) // 2h from now
|
||||||
var warnings = calculateCascadeWarnings(
|
var warnings = calculateCascadeWarnings(
|
||||||
targetTime: targetTime,
|
targetTime: targetTime,
|
||||||
intervals: [60, 30, 15, 5],
|
intervals: [60, 30, 15, 5],
|
||||||
now: now
|
now: now
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check at a time when 60m warning should fire (targetTime - 60min = now)
|
// All warnings should be unfired initially
|
||||||
let checkTime = targetTime.addingTimeInterval(-3600) // exactly at 60m warning
|
XCTAssertTrue(warnings.allSatisfy { !$0.fired })
|
||||||
let fired = checkWarnings(&warnings, now: checkTime.addingTimeInterval(1)) // 1 second after
|
|
||||||
|
// 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
|
// 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() {
|
func testCheckWarningsNoneFire() {
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import { getRemainingMs } from '@/lib/timer-engine';
|
|||||||
import type { Timer } from '@/lib/timer-engine';
|
import type { Timer } from '@/lib/timer-engine';
|
||||||
import { CountdownRing } from './CountdownRing';
|
import { CountdownRing } from './CountdownRing';
|
||||||
import { formatDuration } from '@/lib/format';
|
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 {
|
interface PomodoroViewProps {
|
||||||
timer: Timer;
|
timer: Timer;
|
||||||
@ -26,12 +27,62 @@ export function PomodoroView({ timer }: PomodoroViewProps) {
|
|||||||
const isBreak = pomState.isBreak || pomState.isLongBreak;
|
const isBreak = pomState.isBreak || pomState.isLongBreak;
|
||||||
const isPaused = timer.state === 'paused';
|
const isPaused = timer.state === 'paused';
|
||||||
const isFiring = timer.state === 'firing';
|
const isFiring = timer.state === 'firing';
|
||||||
|
const isCompleted = timer.state === 'completed';
|
||||||
const roundLabel = isBreak
|
const roundLabel = isBreak
|
||||||
? pomState.isLongBreak ? 'Long Break' : 'Break'
|
? pomState.isLongBreak ? 'Long Break' : 'Break'
|
||||||
: `Round ${pomState.currentRound} of ${config.rounds}`;
|
: `Round ${pomState.currentRound} of ${config.rounds}`;
|
||||||
|
|
||||||
const ringColor = isBreak ? 'var(--cm-accent-secondary)' : 'var(--cm-accent)';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border p-6 text-center"
|
className="rounded-2xl border p-6 text-center"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user