Skip to content

Commit 7e99774

Browse files
authored
Merge pull request #12 from MinitJain/feat/watcher-settings-ambient
Feat/watcher settings ambient
2 parents 8c24eb6 + 3b688c0 commit 7e99774

24 files changed

Lines changed: 210 additions & 36 deletions

README.md

Lines changed: 136 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,45 +11,149 @@
1111

1212
**[→ Try it live](https://pomodoro-jam.vercel.app)** · [Report a bug](https://github.com/MinitJain/pomodoro-jam/issues) · [Request a feature](https://github.com/MinitJain/pomodoro-jam/issues)
1313

14-
<!-- Add a screenshot or GIF here after your first deploy — drag an image into this edit field on GitHub -->
14+
![Landing Page](docs/screenshots/landing.png)
15+
![Landing page features](docs/screenshots/landing2.png)
16+
![Landing page onboarding](docs/screenshots/landing3.png)
1517

1618
</div>
1719

1820
---
1921

2022
## Why PomodoroJam?
2123

22-
- **Solo focus is hard.** Shared accountability makes it stick.
23-
- **One host starts a timer, shares a link.** Everyone joins and sees the same second.
24-
- **When it ends, everyone breaks together.** No meetings. No distractions. Just shared focus.
24+
Solo focus is hard. Shared accountability makes it stick. One person starts a timer, shares a link — everyone joins and sees the exact same second. When it ends, everyone breaks together. No meetings. No coordination. Just shared focus.
2525

2626
---
2727

2828
## Features
2929

30-
| | Feature | Description |
31-
|---|---|---|
32-
| 🎯 | **Synchronized timer** | Clock-based, drift-resistant. Everyone is on the same second, always. |
33-
| 🎸 | **Jam Mode** | Switch between Host (you control) and Jam (everyone controls) mid-session. |
34-
| 🔊 | **Focus Noise** | Brown, white, pink noise and rain — generated by Web Audio API, no CDN. |
35-
| 👥 | **Live participants** | See who's focusing with you via real-time presence. |
36-
| 🏷️ | **Guest nicknames** | Guests can set a display name — visible to everyone in the session. |
37-
| 🔔 | **Break notifications** | Browser push notification when your session ends. |
38-
| 📊 | **Analytics dashboard** | Track pomodoros, focus hours, streaks, and a GitHub-style activity heatmap. |
39-
| 🔄 | **Auto start** | Optionally auto-start breaks or next focus sessions when the timer ends. |
40-
| ⏭️ | **Round cycle** | Configurable long-break interval (default every 4 sessions). |
41-
| 💬 | **Activity feed** | Live floating messages when participants start, pause, skip, or join. |
42-
| 🔗 | **One-tap share** | Copy link or use the native OS share sheet. |
43-
| 🌍 | **Explore page** | Browse live sessions happening right now. |
44-
| 👋 | **No account needed** | Start and join sessions as a guest. |
45-
| 🌗 | **Dark / light theme** | Persisted per device via next-themes. |
46-
| 📱 | **PWA** | Installable on mobile with custom icons. |
30+
### Synchronized Timer
31+
32+
![Focus timer](docs/screenshots/focusTimer.png)
33+
![Short break timer](docs/screenshots/shortBreakTimer.png)
34+
![Long break timer](docs/screenshots/longBreakTimer.png)
35+
36+
The timer is clock-based using a `startedAt` Unix timestamp rather than a local countdown. This means everyone in the session — regardless of when they joined or how bad their connection is — always sees the correct second. No drift, no skew.
37+
38+
---
39+
40+
### Host Mode & Jam Mode
41+
42+
![Host mode](docs/screenshots/hostMode.png)
43+
![Jam mode](docs/screenshots/jamMode.png)
44+
45+
Every session has two control modes you can switch between at any time:
46+
47+
- **Host mode** — only the host can start, pause, skip, or change settings. Everyone else follows along in sync.
48+
- **Jam mode** — anyone in the session can control the timer, like a group decision. Great for teams where everyone should have equal say.
49+
50+
The current mode is visible to all participants in real time.
51+
52+
---
53+
54+
### Watcher Settings Requests
55+
56+
![Watcher settings request](docs/screenshots/watcherSettings.png)
57+
58+
Watchers can't change settings directly, but they can request a change. The host receives an inline card showing exactly what the watcher wants to change — a diff of old vs. new values — and can accept or reject with one tap. If accepted, the settings apply immediately for everyone.
59+
60+
---
61+
62+
### Focus Noise
63+
64+
![Focus noise panel](docs/screenshots/focusNoise.png)
65+
66+
Four ambient sounds generated entirely by the Web Audio API — no CDN, no external files, no latency:
67+
68+
- 🟤 **Brown noise** — deep, rumbling background hum
69+
-**White noise** — broad-spectrum static
70+
- 🌸 **Pink noise** — softer mid-range tone
71+
- 🌧️ **Rain** — gentle rainfall texture
72+
73+
The panel is collapsible. A green pulse dot appears next to "Focus Noise" when a sound is active, so you always know something's playing.
74+
75+
---
76+
77+
### Live Participants & Activity Feed
78+
79+
See who's focusing alongside you via real-time presence. When someone joins or leaves, a floating activity message appears at the bottom of the screen. The same feed shows timer events — when someone starts, pauses, or skips — so the whole group stays in the loop without any chat.
80+
81+
---
82+
83+
### Guest Nicknames
84+
85+
![Guest nickname prompt](docs/screenshots/guestNickname.png)
86+
87+
No account needed. Guests are prompted to set a display name when they first join a session. The name is saved per-session in `localStorage` so it persists across page reloads. It shows up in the participant list and activity feed for everyone in the room.
88+
89+
---
90+
91+
### Timer Settings
92+
93+
![Settings panel](docs/screenshots/Settings.png)
94+
95+
Fully configurable per-session:
96+
97+
- Focus, short break, and long break durations (in minutes)
98+
- Long break interval (how many focus rounds before a long break)
99+
- Auto-start breaks — break timer starts automatically when focus ends
100+
- Auto-start pomodoros — focus timer restarts automatically after a break
101+
102+
Settings are persisted to the database and broadcast to all participants when applied.
103+
104+
---
105+
106+
### Break Overlay & Notifications
107+
108+
![Break overlay](docs/screenshots/breakOverlay.png)
109+
110+
When the timer ends, a full-screen break overlay appears for all participants simultaneously. Browser push notifications fire at the same moment — useful if you've switched tabs. The overlay disappears as soon as the next session starts.
111+
112+
---
113+
114+
### Analytics Dashboard
115+
116+
![Analytics dashboard](docs/screenshots/analyticsDashboard.png)
117+
118+
Authenticated users get a personal analytics dashboard at `/profile/[username]`:
119+
120+
- **Total pomodoros** and **focus hours** completed
121+
- **Current streak** — consecutive days with at least one completed session
122+
- **Weekly bar chart** — last 7 days of focus activity
123+
- **GitHub-style heatmap** — 52-week calendar showing daily pomodoro counts
124+
125+
All data is logged to the `pomodoro_logs` table whenever a focus session completes.
126+
127+
---
128+
129+
### Explore Page
130+
131+
![Explore page](docs/screenshots/explorePage.png)
132+
133+
Browse all live sessions happening right now without needing a direct link. The explore page shows active sessions updated in the last 5 minutes — session title, host name, and current mode. Click any card to join instantly.
134+
135+
---
136+
137+
### One-Tap Share
138+
139+
<!-- SCREENSHOT: Share panel / invite button -->
140+
141+
The share panel lets you copy the session link or trigger the native OS share sheet (on mobile). Guests can only share if the host has "Allow guests to invite" enabled in settings — the host controls whether the room is open or invite-only.
142+
143+
---
144+
145+
### Dark / Light Theme & PWA
146+
147+
![Dark mode](docs/screenshots/darkMode.png)
148+
![Light mode](docs/screenshots/lightMode.png)
149+
150+
Theme toggles between dark and light, persisted per device. The app is installable as a PWA on mobile and desktop — the favicon and tab title update live with the current timer countdown.
47151

48152
---
49153

50154
## How it works
51155

52-
1. **Start** — click "Start a session" and choose Host or Join
156+
1. **Start** — click "Start a session" on the landing page
53157
2. **Share** — hit the Invite button and send the link to anyone
54158
3. **Pick your mode** — toggle between **Host** (you lead) and **Jam** (everyone drives)
55159
4. **Focus** — hit Play. Everyone sees the same countdown, on the same second
@@ -61,16 +165,16 @@ No account needed. Start in under 30 seconds.
61165

62166
## Stack
63167

64-
| Layer | Technology |
65-
|---|---|
66-
| Framework | Next.js 14 App Router |
67-
| Language | TypeScript (strict) |
68-
| Database | Supabase (PostgreSQL + Realtime) |
69-
| Auth | Supabase Auth (GitHub + Google OAuth) |
70-
| Styling | Tailwind CSS + CSS variables |
71-
| Theme | next-themes (dark/light, persisted) |
72-
| OG Images | @vercel/og |
73-
| Fonts | Syne + DM Sans + JetBrains Mono |
168+
| Layer | Technology |
169+
| --------- | ------------------------------------- |
170+
| Framework | Next.js 14 App Router |
171+
| Language | TypeScript (strict) |
172+
| Database | Supabase (PostgreSQL + Realtime) |
173+
| Auth | Supabase Auth (GitHub + Google OAuth) |
174+
| Styling | Tailwind CSS + CSS variables |
175+
| Theme | next-themes (dark/light, persisted) |
176+
| OG Images | @vercel/og |
177+
| Fonts | Syne + DM Sans + JetBrains Mono |
74178

75179
---
76180

app/session/[id]/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export async function generateMetadata({ params }: SessionPageProps): Promise<Me
2121
const focusMins: number = (data?.settings as { focus?: number } | null)?.focus ?? 25
2222
const ogTitle = `${hostName} invited you to PomodoroJam 🍅`
2323
const ogDesc = `Join ${hostName}'s ${focusMins}-min focus session. Open the link to join.`
24+
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://pomodoro-jam.vercel.app'
2425

2526
return {
2627
title: `${hostName}'s focus session`,
@@ -30,7 +31,7 @@ export async function generateMetadata({ params }: SessionPageProps): Promise<Me
3031
description: ogDesc,
3132
images: [
3233
{
33-
url: `/api/og?type=invite&host=${encodeURIComponent(hostName)}&focus=${focusMins}`,
34+
url: `${appUrl}/api/og?type=invite&host=${encodeURIComponent(hostName)}&focus=${focusMins}`,
3435
width: 1200,
3536
height: 630,
3637
},
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client'
2+
3+
import { X } from 'lucide-react'
4+
5+
interface MissedEventsToastProps {
6+
events: string[]
7+
onDismiss: () => void
8+
}
9+
10+
export function MissedEventsToast({ events, onDismiss }: MissedEventsToastProps) {
11+
return (
12+
<div
13+
role="status"
14+
aria-live="polite"
15+
className="fixed bottom-6 left-4 sm:left-6 z-50 flex flex-col pointer-events-auto animate-fade-up"
16+
style={{ maxWidth: '280px' }}
17+
>
18+
<div
19+
className="flex items-center justify-between px-3 py-1.5 rounded-t-xl text-xs font-medium"
20+
style={{ background: 'var(--accent-soft)', border: '1px solid var(--border)', borderBottom: 'none', color: 'var(--accent)' }}
21+
>
22+
<span>While you were away</span>
23+
<button type="button" onClick={onDismiss} aria-label="Dismiss" className="ml-2 cursor-pointer opacity-70 hover:opacity-100 transition-opacity" style={{ color: 'var(--accent)' }}><X size={12} aria-hidden="true" /></button>
24+
</div>
25+
<div className="flex flex-col rounded-b-xl overflow-hidden" style={{ border: '1px solid var(--border)', borderTop: 'none', background: 'var(--bg-elevated)', boxShadow: 'var(--shadow-md)', backdropFilter: 'blur(8px)' }}>
26+
{events.map((text, i) => (
27+
<div key={`${text}-${i}`} className="px-3.5 py-1.5 text-xs leading-snug" style={{ color: 'var(--text-secondary)', borderTop: i > 0 ? '1px solid var(--border)' : 'none' }}>
28+
{text}
29+
</div>
30+
))}
31+
</div>
32+
</div>
33+
)
34+
}

components/session/SessionProvider.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { TimerRing } from '@/components/timer/TimerRing'
1313
import { TimerDisplay } from '@/components/timer/TimerDisplay'
1414
import { TimerControls } from '@/components/timer/TimerControls'
1515
import { ModeSelector } from '@/components/timer/ModeSelector'
16+
import { MissedEventsToast } from '@/components/session/MissedEventsToast'
1617
import { ParticipantList } from '@/components/session/ParticipantList'
1718
import { SharePanel } from '@/components/session/SharePanel'
1819
import { ActivityFeed } from '@/components/session/ActivityFeed'
@@ -69,6 +70,12 @@ function SessionContent({
6970
const focusCountRef = useRef(0)
7071
const [focusCount, setFocusCount] = useState(0)
7172
const [activities, setActivities] = useState<ActivityItem[]>([])
73+
const sessionLogRef = useRef<string[]>([])
74+
const totalLogCountRef = useRef<number>(0)
75+
const tabHiddenAtRef = useRef<number | null>(null)
76+
const logCountAtHideRef = useRef<number>(0)
77+
const missedEventsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
78+
const [missedEvents, setMissedEvents] = useState<string[]>([])
7279
// localUsername: for guests this starts null and is set when they save a nickname
7380
const [localUsername, setLocalUsername] = useState<string | null>(username ?? null)
7481
const [showNicknamePrompt, setShowNicknamePrompt] = useState(false)
@@ -178,7 +185,7 @@ function SessionContent({
178185
sessionId: session.id,
179186
userId,
180187
isHost,
181-
username,
188+
username: localUsername,
182189
avatarUrl,
183190
})
184191

@@ -199,11 +206,36 @@ function SessionContent({
199206

200207
// Push an ephemeral activity item — auto-removes after animation completes
201208
const pushActivity = useCallback((text: string) => {
209+
totalLogCountRef.current++
210+
sessionLogRef.current = [...sessionLogRef.current, text].slice(-10)
202211
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
203212
setActivities(prev => [...prev.slice(-3), { id, text }])
204213
setTimeout(() => setActivities(prev => prev.filter(a => a.id !== id)), 2900)
205214
}, [])
206215

216+
useEffect(() => {
217+
function handleVisibilityChange() {
218+
if (document.visibilityState === 'hidden') {
219+
tabHiddenAtRef.current = Date.now()
220+
logCountAtHideRef.current = totalLogCountRef.current
221+
} else {
222+
if (tabHiddenAtRef.current === null) return
223+
tabHiddenAtRef.current = null
224+
const newCount = totalLogCountRef.current - logCountAtHideRef.current
225+
const missed = newCount > 0 ? sessionLogRef.current.slice(-newCount) : []
226+
if (missed.length === 0) return
227+
setMissedEvents(missed)
228+
if (missedEventsTimerRef.current) clearTimeout(missedEventsTimerRef.current)
229+
missedEventsTimerRef.current = setTimeout(() => setMissedEvents([]), 4000)
230+
}
231+
}
232+
document.addEventListener('visibilitychange', handleVisibilityChange)
233+
return () => {
234+
document.removeEventListener('visibilitychange', handleVisibilityChange)
235+
if (missedEventsTimerRef.current) clearTimeout(missedEventsTimerRef.current)
236+
}
237+
}, [])
238+
207239
// Receive activity broadcasts from other participants
208240
useEffect(() => {
209241
return onActivity(pushActivity)
@@ -835,6 +867,9 @@ function SessionContent({
835867
canControl={canControl}
836868
/>
837869

870+
{missedEvents.length > 0 && (
871+
<MissedEventsToast events={missedEvents} onDismiss={() => setMissedEvents([])} />
872+
)}
838873
<ActivityFeed items={activities} />
839874

840875
{isHost && pendingRequest && (

docs/screenshots/Settings.png

294 KB
Loading
237 KB
Loading

docs/screenshots/breakOverlay.png

282 KB
Loading

docs/screenshots/darkMode.png

229 KB
Loading

docs/screenshots/explorePage.png

179 KB
Loading

docs/screenshots/focusNoise.png

216 KB
Loading

0 commit comments

Comments
 (0)