Initial commit: Pokerface sprint planning poker for Jira

Full-stack app with Express/Socket.io backend, React frontend,
NATS JetStream for state, and Atlassian Jira OAuth integration.

Includes security hardening: NATS auth support, KV bucket TTL
enforcement, CAS retry for race conditions, error message
sanitization, and OAuth state stored in NATS KV.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jan Willem Mannaerts 2026-02-26 21:38:37 +01:00
commit fdd9ba8d56
36 changed files with 7596 additions and 0 deletions

1
frontend/.env.example Normal file
View file

@ -0,0 +1 @@
VITE_API_URL=http://localhost:4010/api

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Pokerface</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2375
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
frontend/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "pokerface-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"highlight.js": "^11.11.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"socket.io-client": "^4.8.1",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.4",
"vite": "^7.1.7"
}
}

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0f172a"/>
<rect x="4" y="3" width="24" height="26" rx="3" fill="none" stroke="#10b981" stroke-width="2"/>
<text x="16" y="20.5" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="800" font-size="12" fill="#10b981">PF</text>
</svg>

After

Width:  |  Height:  |  Size: 359 B

137
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import LoginScreen from './components/LoginScreen';
import Lobby from './components/Lobby';
import Room from './components/Room';
import LegalPage from './components/LegalPage';
import { api } from './services/api';
function useDarkMode() {
const [dark, setDark] = useState(() => {
const saved = localStorage.getItem('pokerface-dark');
if (saved !== null) return saved === 'true';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem('pokerface-dark', dark);
}, [dark]);
return [dark, () => setDark((d) => !d)];
}
export default function App() {
const [view, setView] = useState('loading');
const [user, setUser] = useState(null);
const [activeRoom, setActiveRoom] = useState(null);
const [prevView, setPrevView] = useState('login');
const [dark, toggleDark] = useDarkMode();
useEffect(() => {
checkAuth();
}, []);
async function checkAuth() {
const params = new URLSearchParams(window.location.search);
if (params.get('auth') === 'error') {
const message = params.get('message') || 'Authentication failed.';
console.error('[auth] OAuth callback error:', message);
window.history.replaceState({}, '', window.location.pathname);
setView('login');
return;
}
if (params.has('auth')) {
window.history.replaceState({}, '', window.location.pathname);
}
try {
const me = await api.getMe();
setUser(me);
// Restore room from URL if present
const roomId = new URLSearchParams(window.location.search).get('room');
if (roomId) {
try {
const { room } = await api.getRoom(roomId);
if (room) {
setActiveRoom(room);
setView('room');
return;
}
} catch {
// room gone, fall through to lobby
window.history.replaceState({}, '', window.location.pathname);
}
}
setView('lobby');
} catch {
setView('login');
}
}
async function handleLogout() {
try {
await api.logout();
} catch {
// ignore
}
setUser(null);
setActiveRoom(null);
setView('login');
window.history.replaceState({}, '', window.location.pathname);
}
if (view === 'loading') {
return (
<div className="min-h-screen flex items-center justify-center dark:bg-slate-900">
<p className="text-slate-400 text-sm tracking-wide">Loading...</p>
</div>
);
}
function showLegal(page) {
setPrevView(view);
setView(`legal-${page}`);
}
if (view.startsWith('legal-')) {
const page = view.replace('legal-', '');
return <LegalPage page={page} dark={dark} onBack={() => setView(prevView)} />;
}
if (view === 'login') {
return <LoginScreen dark={dark} toggleDark={toggleDark} onShowLegal={showLegal} />;
}
if (view === 'room' && activeRoom) {
return (
<Room
room={activeRoom}
user={user}
dark={dark}
toggleDark={toggleDark}
onBack={() => {
setActiveRoom(null);
setView('lobby');
window.history.replaceState({}, '', window.location.pathname);
}}
/>
);
}
return (
<Lobby
user={user}
dark={dark}
toggleDark={toggleDark}
onEnterRoom={(room) => {
setActiveRoom(room);
setView('room');
window.history.replaceState({}, '', `?room=${room.id}`);
}}
onLogout={handleLogout}
onShowLegal={showLegal}
/>
);
}

View file

@ -0,0 +1,194 @@
import React, { useEffect, useRef } from 'react';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import python from 'highlight.js/lib/languages/python';
import java from 'highlight.js/lib/languages/java';
import sql from 'highlight.js/lib/languages/sql';
import json from 'highlight.js/lib/languages/json';
import xml from 'highlight.js/lib/languages/xml';
import css from 'highlight.js/lib/languages/css';
import bash from 'highlight.js/lib/languages/bash';
import csharp from 'highlight.js/lib/languages/csharp';
import yaml from 'highlight.js/lib/languages/yaml';
import 'highlight.js/styles/github-dark.min.css';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('js', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('ts', typescript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('py', python);
hljs.registerLanguage('java', java);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('json', json);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('html', xml);
hljs.registerLanguage('css', css);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('sh', bash);
hljs.registerLanguage('shell', bash);
hljs.registerLanguage('csharp', csharp);
hljs.registerLanguage('c#', csharp);
hljs.registerLanguage('yaml', yaml);
function CodeBlock({ code, language }) {
const elRef = useRef(null);
useEffect(() => {
if (!elRef.current) return;
const codeEl = elRef.current.querySelector('code');
if (!codeEl) return;
// Reset for re-highlight
codeEl.removeAttribute('data-highlighted');
codeEl.className = language && hljs.getLanguage(language) ? `language-${language}` : '';
codeEl.textContent = code;
try {
hljs.highlightElement(codeEl);
} catch {
// fallback: no highlighting
}
}, [code, language]);
return (
<pre ref={elRef} className="bg-slate-900 dark:bg-slate-950 text-slate-100 rounded p-3 my-2 overflow-x-auto text-xs font-mono">
<code>{code}</code>
</pre>
);
}
function parseWikiInline(text) {
const parts = [];
const regex = /\*([^*\n]+)\*|_([^_\n]+)_|-([^-\n]+)-|\+([^+\n]+)\+|\{\{([^}]*?)\}\}|\[([^|\]\n]+)\|([^\]\n]+?)(?:\|[^\]]*?)?\]|\[(https?:\/\/[^\]\s]+)\]/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
if (match[1] != null) {
parts.push(<strong key={match.index}>{match[1]}</strong>);
} else if (match[2] != null) {
parts.push(<em key={match.index}>{match[2]}</em>);
} else if (match[3] != null) {
parts.push(<del key={match.index}>{match[3]}</del>);
} else if (match[4] != null) {
parts.push(<u key={match.index}>{match[4]}</u>);
} else if (match[5] != null) {
parts.push(<code key={match.index} className="bg-slate-100 dark:bg-slate-700 text-red-600 dark:text-red-400 px-1.5 py-0.5 rounded text-sm font-mono">{match[5]}</code>);
} else if (match[6] != null && match[7] != null) {
parts.push(<a key={match.index} href={match[7]} target="_blank" rel="noopener noreferrer" className="text-emerald-600 dark:text-emerald-400 hover:underline">{match[6]}</a>);
} else if (match[8] != null) {
parts.push(<a key={match.index} href={match[8]} target="_blank" rel="noopener noreferrer" className="text-emerald-600 dark:text-emerald-400 hover:underline">{match[8]}</a>);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts.length === 0 ? [text] : parts;
}
function renderWikiMarkup(text) {
const elements = [];
// Extract code blocks first
const segments = [];
const codeRegex = /\{(code|noformat)(?::([^}]*))?\}([\s\S]*?)\{\1\}/g;
let lastIdx = 0;
let m;
while ((m = codeRegex.exec(text)) !== null) {
if (m.index > lastIdx) segments.push({ type: 'text', value: text.slice(lastIdx, m.index) });
segments.push({ type: 'code', value: m[3], language: m[2] || '' });
lastIdx = m.index + m[0].length;
}
if (lastIdx < text.length) segments.push({ type: 'text', value: text.slice(lastIdx) });
for (const seg of segments) {
if (seg.type === 'code') {
elements.push(<CodeBlock key={elements.length} code={seg.value.trim()} language={seg.language} />);
continue;
}
const lines = seg.value.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i];
const headingMatch = line.match(/^h([1-6])\.\s+(.*)/);
if (headingMatch) {
const level = headingMatch[1];
const Tag = `h${level}`;
const sizes = { 1: 'text-xl font-bold mb-3 mt-4', 2: 'text-lg font-bold mb-2 mt-3', 3: 'text-base font-semibold mb-2 mt-3', 4: 'text-sm font-semibold mb-1 mt-2', 5: 'text-xs font-semibold mb-1 mt-2', 6: 'text-xs font-semibold mb-1 mt-1' };
elements.push(<Tag key={elements.length} className={sizes[level]}>{parseWikiInline(headingMatch[2])}</Tag>);
i++; continue;
}
if (line.match(/^----?\s*$/)) {
elements.push(<hr key={elements.length} className="my-4 border-slate-200 dark:border-slate-700" />);
i++; continue;
}
const bqMatch = line.match(/^bq\.\s+(.*)/);
if (bqMatch) {
elements.push(
<blockquote key={elements.length} className="border-l-2 border-slate-300 dark:border-slate-600 pl-3 italic text-slate-500 dark:text-slate-400 my-2">
<p>{parseWikiInline(bqMatch[1])}</p>
</blockquote>
);
i++; continue;
}
if (line.match(/^\*+\s+/)) {
const items = [];
while (i < lines.length && lines[i].match(/^\*+\s+/)) {
const lm = lines[i].match(/^(\*+)\s+(.*)/);
items.push({ depth: lm[1].length, text: lm[2] });
i++;
}
elements.push(
<ul key={elements.length} className="list-disc pl-5 mb-2 space-y-0.5">
{items.map((item, j) => <li key={j} className="leading-relaxed" style={{ marginLeft: `${(item.depth - 1) * 16}px` }}>{parseWikiInline(item.text)}</li>)}
</ul>
);
continue;
}
if (line.match(/^#+\s+/)) {
const items = [];
while (i < lines.length && lines[i].match(/^#+\s+/)) {
const lm = lines[i].match(/^(#+)\s+(.*)/);
items.push({ depth: lm[1].length, text: lm[2] });
i++;
}
elements.push(
<ol key={elements.length} className="list-decimal pl-5 mb-2 space-y-0.5">
{items.map((item, j) => <li key={j} className="leading-relaxed" style={{ marginLeft: `${(item.depth - 1) * 16}px` }}>{parseWikiInline(item.text)}</li>)}
</ol>
);
continue;
}
if (line.trim() === '') { i++; continue; }
elements.push(<p key={elements.length} className="mb-2 leading-relaxed">{parseWikiInline(line)}</p>);
i++;
}
}
return elements;
}
export default function AdfRenderer({ document, className = '', fallback = '' }) {
if (!document) {
return fallback ? <p className="text-slate-400 italic text-sm">{fallback}</p> : null;
}
if (typeof document === 'string') {
return <div className={`text-sm ${className}`.trim()}>{renderWikiMarkup(document)}</div>;
}
return fallback ? <p className="text-slate-400 italic text-sm">{fallback}</p> : null;
}

View file

@ -0,0 +1,19 @@
export default function DarkModeToggle({ dark, toggleDark, className = '' }) {
return (
<button
onClick={toggleDark}
className={`w-8 h-8 flex items-center justify-center transition-colors cursor-pointer border-none bg-transparent ${className}`}
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? (
<svg className="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-4 h-4 text-slate-400 hover:text-slate-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</button>
);
}

View file

@ -0,0 +1,239 @@
export default function LegalPage({ page, dark, onBack }) {
return (
<div className="min-h-screen flex flex-col" style={{ background: dark ? '#09090b' : '#f0f1f5' }}>
<header className="flex items-center gap-3 px-5 py-3 shrink-0">
<button
onClick={onBack}
className="text-sm font-medium px-3 py-1 border-none cursor-pointer transition-colors"
style={{
color: dark ? '#94a3b8' : '#64748b',
background: dark ? '#1e293b' : '#e2e8f0'
}}
>
&larr; Back
</button>
<span className="font-syne font-bold text-sm tracking-tight" style={{ color: dark ? '#fff' : '#0f172a' }}>
POKERFACE
</span>
</header>
<main className="flex-1 px-5 py-6 max-w-2xl mx-auto w-full">
{page === 'terms' && <TermsOfService dark={dark} />}
{page === 'privacy' && <PrivacyPolicy dark={dark} />}
{page === 'support' && <Support dark={dark} />}
</main>
</div>
);
}
function Heading({ children, dark }) {
return <h1 className="text-2xl font-bold mb-4 mt-0" style={{ color: dark ? '#fff' : '#0f172a' }}>{children}</h1>;
}
function SubHeading({ children, dark }) {
return <h2 className="text-base font-semibold mt-6 mb-2" style={{ color: dark ? '#e2e8f0' : '#1e293b' }}>{children}</h2>;
}
function P({ children, dark }) {
return <p className="text-sm leading-relaxed my-2" style={{ color: dark ? '#94a3b8' : '#475569' }}>{children}</p>;
}
function Li({ children, dark }) {
return <li className="text-sm leading-relaxed my-1" style={{ color: dark ? '#94a3b8' : '#475569' }}>{children}</li>;
}
function TermsOfService({ dark }) {
return (
<>
<Heading dark={dark}>Terms of Service</Heading>
<P dark={dark}><em>Last updated: February 2026</em></P>
<SubHeading dark={dark}>1. Acceptance</SubHeading>
<P dark={dark}>
By accessing or using Pokerface ("the Service"), you agree to these terms.
If you do not agree, do not use the Service.
</P>
<SubHeading dark={dark}>2. Description</SubHeading>
<P dark={dark}>
Pokerface is a free sprint planning poker tool that integrates with Atlassian Jira.
It is provided as a convenience for agile teams to facilitate estimation sessions.
</P>
<SubHeading dark={dark}>3. No Warranty</SubHeading>
<P dark={dark}>
THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND,
WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
NON-INFRINGEMENT. NO ADVICE OR INFORMATION, WHETHER ORAL OR WRITTEN, OBTAINED FROM
THE SERVICE SHALL CREATE ANY WARRANTY NOT EXPRESSLY STATED HEREIN.
</P>
<SubHeading dark={dark}>4. Limitation of Liability</SubHeading>
<P dark={dark}>
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE DEVELOPERS,
OPERATORS, OR CONTRIBUTORS OF POKERFACE BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, DATA, USE, OR
GOODWILL, HOWEVER CAUSED AND UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, OR OTHERWISE, ARISING OUT OF OR IN
CONNECTION WITH YOUR ACCESS TO OR USE OF (OR INABILITY TO USE) THE SERVICE.
</P>
<P dark={dark}>
THE TOTAL AGGREGATE LIABILITY OF THE SERVICE OPERATORS FOR ALL CLAIMS RELATING TO
THE SERVICE SHALL NOT EXCEED ZERO EUROS (EUR 0.00).
</P>
<SubHeading dark={dark}>5. No Guarantee of Availability</SubHeading>
<P dark={dark}>
The Service may be modified, suspended, or discontinued at any time without notice.
We are under no obligation to maintain, support, or update the Service.
</P>
<SubHeading dark={dark}>6. User Responsibilities</SubHeading>
<P dark={dark}>
You are responsible for your use of the Service and any data you transmit through it.
You must comply with Atlassian's terms of service when using Jira integration features.
</P>
<SubHeading dark={dark}>7. Third-Party Services</SubHeading>
<P dark={dark}>
Pokerface integrates with Atlassian Jira via OAuth. Your use of Jira is governed by
Atlassian's own terms and privacy policy. We are not responsible for any third-party
service availability or behavior.
</P>
<SubHeading dark={dark}>8. Changes to Terms</SubHeading>
<P dark={dark}>
These terms may be updated at any time. Continued use of the Service after changes
constitutes acceptance of the revised terms.
</P>
</>
);
}
function PrivacyPolicy({ dark }) {
return (
<>
<Heading dark={dark}>Privacy Policy</Heading>
<P dark={dark}><em>Last updated: February 2026</em></P>
<SubHeading dark={dark}>1. What Data We Collect</SubHeading>
<P dark={dark}>
When you sign in with Jira, we receive the following information from Atlassian via OAuth:
</P>
<ul className="pl-5 my-2">
<Li dark={dark}><strong>Jira account ID</strong> your unique Atlassian identifier</Li>
<Li dark={dark}><strong>Display name</strong> your Jira profile name</Li>
<Li dark={dark}><strong>Avatar URL</strong> a link to your Jira profile picture</Li>
<Li dark={dark}><strong>Email address</strong> your Jira account email</Li>
<Li dark={dark}><strong>Cloud ID and site URL</strong> identifies your Jira workspace</Li>
<Li dark={dark}><strong>OAuth tokens</strong> access and refresh tokens for Jira API calls</Li>
</ul>
<P dark={dark}>
During poker sessions, we temporarily store:
</P>
<ul className="pl-5 my-2">
<Li dark={dark}>Room and session metadata (project name, sprint name, issue keys)</Li>
<Li dark={dark}>Participant names and avatar URLs</Li>
<Li dark={dark}>Votes submitted during estimation sessions</Li>
</ul>
<SubHeading dark={dark}>2. How We Use Your Data</SubHeading>
<P dark={dark}>
Your data is used solely to operate the poker planning functionality:
authenticating you with Jira, displaying participants in sessions, recording votes,
and writing agreed estimates back to Jira issues. We do not use your data for
analytics, advertising, profiling, or any other purpose.
</P>
<SubHeading dark={dark}>3. Data Storage and Retention</SubHeading>
<P dark={dark}>
All data is stored in NATS JetStream key-value buckets with automatic time-to-live (TTL) expiration:
</P>
<ul className="pl-5 my-2">
<Li dark={dark}><strong>OAuth connections</strong> automatically deleted after 24 hours</Li>
<Li dark={dark}><strong>Rooms and sessions</strong> automatically deleted after 24 hours</Li>
<Li dark={dark}><strong>OAuth state tokens</strong> automatically deleted after 10 minutes</Li>
</ul>
<P dark={dark}>
There is no long-term database. All session data is ephemeral and automatically purged
by TTL. When a poker session is saved, the session data is deleted immediately.
</P>
<SubHeading dark={dark}>4. Cookies</SubHeading>
<P dark={dark}>
Pokerface uses a single, strictly functional cookie:
</P>
<ul className="pl-5 my-2">
<Li dark={dark}>
<strong>pokerface_session</strong> an HttpOnly, Secure, SameSite=Lax JWT cookie
that contains your Jira account ID, cloud ID, display name, and avatar URL. It
expires after 24 hours. This cookie is required for the application to function.
</Li>
</ul>
<P dark={dark}>
We do not use tracking cookies, analytics cookies, or any third-party cookies.
A dark mode preference is stored in your browser's localStorage (not a cookie)
and never sent to our servers.
</P>
<SubHeading dark={dark}>5. Data Sharing</SubHeading>
<P dark={dark}>
We do not sell, share, or transfer your personal data to any third party.
The only external communication is between our backend and Atlassian's Jira API,
using the OAuth tokens you authorized, to read project/sprint data and write
estimates back to issues.
</P>
<SubHeading dark={dark}>6. Data Security</SubHeading>
<P dark={dark}>
All traffic is encrypted via HTTPS in production. Session cookies are marked HttpOnly
and Secure. Security headers (HSTS, X-Frame-Options DENY, nosniff) are applied to all
responses. OAuth tokens are stored server-side only and never exposed to the browser.
</P>
<SubHeading dark={dark}>7. Your Rights</SubHeading>
<P dark={dark}>
Since all data expires automatically within 24 hours, there is no persistent personal
data to request deletion of. You can sign out at any time to clear your session cookie.
Revoking the Pokerface OAuth connection in your Atlassian account settings will
invalidate all stored tokens.
</P>
</>
);
}
function Support({ dark }) {
return (
<>
<Heading dark={dark}>Support</Heading>
<P dark={dark}><em>Last updated: February 2026</em></P>
<SubHeading dark={dark}>About Pokerface</SubHeading>
<P dark={dark}>
Pokerface is a free, open tool for sprint planning poker with Jira integration.
It is provided as-is, with no guarantees of availability, support, or maintenance.
</P>
<SubHeading dark={dark}>No Formal Support</SubHeading>
<P dark={dark}>
This product does not come with dedicated support, SLAs, or guaranteed response times.
There is no helpdesk, ticketing system, or support team.
</P>
<SubHeading dark={dark}>Best-Effort Assistance</SubHeading>
<P dark={dark}>
If you encounter a bug or issue, you may reach out via the project's repository.
Any assistance is provided on a best-effort basis at the maintainer's discretion.
</P>
<SubHeading dark={dark}>Alternatives</SubHeading>
<P dark={dark}>
If Pokerface does not meet your needs, there are many alternative planning poker tools
available in the Atlassian Marketplace and elsewhere. You are free to stop using
Pokerface at any time simply sign out and revoke the OAuth connection in your
Atlassian account settings.
</P>
</>
);
}

View file

@ -0,0 +1,269 @@
import { useEffect, useState } from 'react';
import { api } from '../services/api';
import SearchSelect from './SearchSelect';
import DarkModeToggle from './DarkModeToggle';
export default function Lobby({ user, dark, toggleDark, onEnterRoom, onLogout, onShowLegal }) {
const [rooms, setRooms] = useState([]);
const [showCreate, setShowCreate] = useState(false);
const [projects, setProjects] = useState([]);
const [boards, setBoards] = useState([]);
const [sprints, setSprints] = useState([]);
const [selectedProject, setSelectedProject] = useState(null);
const [selectedBoard, setSelectedBoard] = useState(null);
const [selectedSprint, setSelectedSprint] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [deleteTarget, setDeleteTarget] = useState(null);
useEffect(() => {
loadRooms();
}, []);
async function loadRooms() {
try {
const { rooms: activeRooms } = await api.getRooms();
setRooms(activeRooms || []);
} catch (_err) {
// ignore
}
}
async function handlePlanSession() {
setShowCreate(true);
setError('');
try {
const { projects: projectList } = await api.getProjects();
setProjects(projectList || []);
} catch (err) {
setError(err.message);
}
}
async function handleProjectChange(projectKey) {
const project = projects.find((p) => p.key === projectKey);
setSelectedProject(project || null);
setSelectedBoard(null);
setSelectedSprint(null);
setBoards([]);
setSprints([]);
if (!projectKey) return;
try {
const { boards: boardList } = await api.getBoards(projectKey);
const list = boardList || [];
setBoards(list);
if (list.length === 1) {
const board = list[0];
setSelectedBoard(board);
try {
const { sprints: sprintList } = await api.getBoardSprints(board.id);
setSprints(sprintList || []);
} catch (err) {
setError(err.message);
}
}
} catch (err) {
setError(err.message);
}
}
async function handleBoardChange(boardId) {
const board = boards.find((b) => b.id === Number(boardId));
setSelectedBoard(board || null);
setSelectedSprint(null);
setSprints([]);
if (!boardId) return;
try {
const { sprints: sprintList } = await api.getBoardSprints(boardId);
setSprints(sprintList || []);
} catch (err) {
setError(err.message);
}
}
async function handleCreateRoom() {
if (!selectedProject || !selectedBoard || !selectedSprint) return;
setLoading(true);
setError('');
try {
const { room } = await api.createRoom({
boardId: selectedBoard.id,
projectKey: selectedProject.key,
projectName: selectedProject.name,
sprintId: selectedSprint.id,
sprintName: selectedSprint.name
});
onEnterRoom(room);
} catch (err) {
setError(err.message);
setLoading(false);
}
}
const projectOptions = projects.map((p) => ({ value: p.key, label: `${p.name} (${p.key})` }));
const boardOptions = boards.map((b) => ({ value: b.id, label: b.name }));
const sprintOptions = sprints.map((s) => ({ value: s.id, label: `${s.name} (${s.state})` }));
return (
<main className="min-h-screen flex flex-col dark:bg-slate-900">
{/* Header */}
<header className="flex justify-between items-center bg-slate-900 dark:bg-slate-950 text-white px-5 py-2 shrink-0">
<div className="flex items-center gap-3">
<span className="font-syne font-bold text-sm tracking-tight">POKERFACE</span>
<span className="text-slate-600 text-xs hidden sm:inline">/</span>
<span className="text-slate-400 text-xs hidden sm:inline">Lobby</span>
</div>
<div className="flex items-center gap-3">
<DarkModeToggle dark={dark} toggleDark={toggleDark} />
{user.avatarUrl && (
<img src={user.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover" />
)}
<span className="text-sm text-slate-300">{user.displayName}</span>
<button
onClick={onLogout}
className="bg-slate-700 hover:bg-slate-600 text-white text-xs font-medium px-3 py-1 transition-colors cursor-pointer border-none"
>
Sign out
</button>
</div>
</header>
{/* Content */}
<section className="flex-1 px-5 py-5 flex flex-col gap-5">
<div className="flex justify-between items-center">
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100 m-0">Active Rooms</h2>
<button
onClick={handlePlanSession}
className="border border-emerald-500/50 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500 hover:text-white font-medium text-sm px-4 py-1.5 transition-all cursor-pointer bg-transparent"
>
Plan Session
</button>
</div>
{error && <p className="text-red-600 dark:text-red-400 text-sm m-0">{error}</p>}
{showCreate && (
<div className="animate-fade-in bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 p-4 grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 items-end relative z-10">
<div className="flex flex-col text-xs font-medium text-slate-500 dark:text-slate-400 gap-1.5 uppercase tracking-wider">
<span>Project</span>
<SearchSelect
options={projectOptions}
value={selectedProject?.key || ''}
onChange={(val) => handleProjectChange(val)}
placeholder="Select a project..."
/>
</div>
{boards.length > 1 && (
<div className="flex flex-col text-xs font-medium text-slate-500 dark:text-slate-400 gap-1.5 uppercase tracking-wider">
<span>Board</span>
<SearchSelect
options={boardOptions}
value={selectedBoard?.id || ''}
onChange={(val) => handleBoardChange(val)}
placeholder="Select a board..."
/>
</div>
)}
{sprints.length > 0 && (
<div className="flex flex-col text-xs font-medium text-slate-500 dark:text-slate-400 gap-1.5 uppercase tracking-wider">
<span>Sprint</span>
<SearchSelect
options={sprintOptions}
value={selectedSprint?.id || ''}
onChange={(val) => {
const s = sprints.find((sp) => sp.id === Number(val));
setSelectedSprint(s || null);
}}
placeholder="Select a sprint..."
/>
</div>
)}
<button
onClick={handleCreateRoom}
disabled={!selectedProject || !selectedBoard || !selectedSprint || loading}
className="bg-emerald-500 hover:bg-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold px-4 py-2 transition-colors cursor-pointer border-none text-sm"
>
{loading ? 'Creating...' : 'Create Room'}
</button>
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3">
{rooms.length === 0 ? (
<p className="text-slate-400 text-sm">No active rooms. Create one to start estimating.</p>
) : (
rooms.map((room, i) => (
<div
key={room.id}
onClick={() => onEnterRoom(room)}
style={{ animationDelay: `${i * 60}ms` }}
className="animate-fade-up border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 cursor-pointer flex flex-col gap-1.5 transition-all hover:border-emerald-400 hover:shadow-lg hover:shadow-emerald-500/5 hover:-translate-y-0.5 group relative"
>
{room.createdByAccountId === user.jiraAccountId && (
<button
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(room);
}}
className="absolute top-2 right-2 w-6 h-6 flex items-center justify-center text-slate-300 dark:text-slate-600 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors bg-transparent border-none cursor-pointer text-base leading-none opacity-0 group-hover:opacity-100"
title="Delete room"
>
&times;
</button>
)}
<strong className="text-sm font-semibold text-slate-900 dark:text-slate-100 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">
{room.projectName || room.projectKey}
</strong>
<span className="text-slate-500 dark:text-slate-400 text-xs">{room.sprintName}</span>
<span className="text-slate-400 dark:text-slate-500 text-xs">Created by {room.createdByName}</span>
</div>
))
)}
</div>
</section>
<footer className="flex justify-center gap-3 px-5 py-3 text-xs" style={{ color: dark ? '#475569' : '#94a3b8' }}>
<button onClick={() => onShowLegal('terms')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Terms</button>
<span>&middot;</span>
<button onClick={() => onShowLegal('privacy')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Privacy</button>
<span>&middot;</span>
<button onClick={() => onShowLegal('support')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Support</button>
</footer>
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setDeleteTarget(null)}>
<div onClick={(e) => e.stopPropagation()} className="bg-white dark:bg-slate-800 w-72 p-5 flex flex-col gap-4 shadow-xl animate-fade-in">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100 m-0">Delete Room</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 m-0">
Remove <strong>{deleteTarget.projectName || deleteTarget.projectKey}</strong> {deleteTarget.sprintName}?
</p>
<div className="flex gap-2 justify-end">
<button
onClick={() => setDeleteTarget(null)}
className="px-3 py-1.5 text-xs font-medium text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 border-none cursor-pointer transition-colors"
>
Cancel
</button>
<button
onClick={() => {
api.deleteRoom(deleteTarget.id).then(loadRooms).catch(() => {});
setDeleteTarget(null);
}}
className="px-3 py-1.5 text-xs font-medium text-white bg-red-500 hover:bg-red-600 border-none cursor-pointer transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</main>
);
}

View file

@ -0,0 +1,72 @@
import { useState } from 'react';
import { api } from '../services/api';
import DarkModeToggle from './DarkModeToggle';
export default function LoginScreen({ dark, toggleDark, onShowLegal }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function handleLogin() {
setLoading(true);
setError('');
try {
const { url } = await api.startJiraOAuth();
window.location.href = url;
} catch (err) {
setError(err.message);
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden bg-[#09090b] dark:bg-[#09090b] light:bg-white transition-colors"
style={{ background: dark ? '#09090b' : '#f0f1f5' }}
>
<DarkModeToggle dark={dark} toggleDark={toggleDark} className="absolute top-4 right-4" />
{/* Ambient glow */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: dark
? 'radial-gradient(ellipse at 50% 40%, rgba(16,185,129,0.07) 0%, transparent 55%)'
: 'radial-gradient(ellipse at 50% 40%, rgba(16,185,129,0.1) 0%, transparent 55%)'
}}
/>
<div className="flex flex-col items-center gap-8 max-w-xs w-full text-center relative z-10">
<div className="animate-fade-up flex flex-col items-center gap-4">
<h1 className="font-syne text-5xl font-extrabold tracking-tight m-0"
style={{ color: dark ? '#fff' : '#0f172a' }}
>
POKERFACE
</h1>
<div className="w-10 h-px bg-emerald-500" />
<p className="text-sm tracking-wide m-0" style={{ color: dark ? '#94a3b8' : '#64748b' }}>
Sprint planning poker for Jira teams
</p>
</div>
<div className="animate-fade-up w-full flex flex-col gap-3" style={{ animationDelay: '120ms' }}>
<button
onClick={handleLogin}
disabled={loading}
className="w-full bg-emerald-500 hover:bg-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed font-semibold px-6 py-3 transition-colors cursor-pointer border-none tracking-wide text-sm"
style={{ color: dark ? '#09090b' : '#fff' }}
>
{loading ? 'Redirecting...' : 'Sign in with Jira'}
</button>
{error && <p className="text-red-400 text-sm m-0 animate-fade-in">{error}</p>}
</div>
<div className="animate-fade-up flex gap-3 text-xs" style={{ animationDelay: '240ms', color: dark ? '#64748b' : '#94a3b8' }}>
<button onClick={() => onShowLegal('terms')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Terms</button>
<span>&middot;</span>
<button onClick={() => onShowLegal('privacy')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Privacy</button>
<span>&middot;</span>
<button onClick={() => onShowLegal('support')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Support</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,255 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { getSocket } from '../services/socket';
const CARDS = [
{ value: '?', label: '?', color: '#94a3b8' },
{ value: '☕', label: '☕', color: '#f59e0b' },
{ value: '0', label: '0', color: '#64748b' },
{ value: '0.5', label: '0.5', color: '#64748b' },
{ value: '1', label: '1', color: '#3b82f6' },
{ value: '2', label: '2', color: '#3b82f6' },
{ value: '3', label: '3', color: '#8b5cf6' },
{ value: '5', label: '5', color: '#8b5cf6' },
{ value: '8', label: '8', color: '#ec4899' },
{ value: '13', label: '13', color: '#ec4899' },
{ value: '20', label: '20', color: '#ef4444' },
{ value: '40', label: '40', color: '#ef4444' },
{ value: '100', label: '100', color: '#dc2626' }
];
export default function PokerRoom({ session, issue, user, onSaved }) {
const [participants, setParticipants] = useState([]);
const [votedUserKeys, setVotedUserKeys] = useState([]);
const [revealed, setRevealed] = useState(false);
const [votes, setVotes] = useState({});
const [myVote, setMyVote] = useState(null);
const [suggestedEstimate, setSuggestedEstimate] = useState(null);
const [average, setAverage] = useState(null);
const [manualEstimate, setManualEstimate] = useState('');
const [error, setError] = useState('');
const socket = useMemo(() => getSocket(), []);
const joinedRef = useRef(null);
useEffect(() => {
function onParticipants(payload) {
if (payload.sessionId !== session.id) return;
setParticipants(payload.participants || []);
setVotedUserKeys(payload.votedUserKeys || []);
}
function onVoteUpdate(payload) {
if (payload.sessionId !== session.id) return;
setVotedUserKeys(payload.votedUserKeys || []);
}
function onRevealed(payload) {
if (payload.sessionId !== session.id) return;
setRevealed(true);
setVotes(payload.votes || {});
setAverage(payload.average ?? null);
setSuggestedEstimate(payload.suggestedEstimate ?? null);
if (payload.suggestedEstimate !== undefined && payload.suggestedEstimate !== null) {
setManualEstimate(String(payload.suggestedEstimate));
}
}
function onSavedPayload(payload) {
if (payload.sessionId !== session.id) return;
onSaved(payload.estimate);
}
function onError(payload) {
setError(payload?.error || 'Unexpected poker error');
}
socket.on('poker:participants', onParticipants);
socket.on('poker:vote-update', onVoteUpdate);
socket.on('poker:revealed', onRevealed);
socket.on('poker:saved', onSavedPayload);
socket.on('poker:error', onError);
// Guard against React strict mode double-invoking effects
if (joinedRef.current !== session.id) {
joinedRef.current = session.id;
socket.emit('poker:join', {
sessionId: session.id,
userKey: user.key,
userName: user.name
});
}
return () => {
socket.off('poker:participants', onParticipants);
socket.off('poker:vote-update', onVoteUpdate);
socket.off('poker:revealed', onRevealed);
socket.off('poker:saved', onSavedPayload);
socket.off('poker:error', onError);
};
}, [session.id, socket, user.key, user.name]);
function handleVote(value) {
if (revealed) return;
setMyVote(value);
socket.emit('poker:vote', {
sessionId: session.id,
userKey: user.key,
vote: value
});
}
function handleSave() {
const estimate = Number(manualEstimate || suggestedEstimate);
if (!Number.isFinite(estimate)) {
setError('Pick a numeric estimate before saving.');
return;
}
socket.emit('poker:save', {
sessionId: session.id,
estimate
});
}
return (
<div className="flex flex-col h-full">
{/* Cards or Results */}
<div className="border-b border-slate-200 dark:border-slate-700">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h2 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider m-0">
{revealed ? 'Results' : 'Choose Your Estimate'}
</h2>
</div>
<div className="p-4 bg-slate-50 dark:bg-slate-800/50">
{revealed ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-6 bg-purple-50 dark:bg-purple-900/20 rounded-sm">
<div className="text-sm text-slate-500 dark:text-slate-400 mb-2">Average</div>
<div className="text-4xl font-bold text-purple-600 dark:text-purple-400">
{average?.toFixed(1) ?? '-'}
</div>
</div>
<div className="text-center p-6 bg-green-50 dark:bg-green-900/20 rounded-sm">
<div className="text-sm text-slate-500 dark:text-slate-400 mb-2">Suggested</div>
<div className="text-4xl font-bold text-green-600 dark:text-green-400">
{suggestedEstimate ?? '-'}
</div>
</div>
</div>
{/* Individual votes */}
<div className="flex flex-wrap gap-2">
{Object.entries(votes).map(([userKey, value]) => {
const name = participants.find((p) => p.userKey === userKey)?.userName || userKey;
return (
<span
key={userKey}
className="text-xs bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm px-3 py-1 text-slate-600 dark:text-slate-300"
>
{name}: <strong>{value}</strong>
</span>
);
})}
</div>
{/* Override + Save */}
<div className="flex gap-3">
<input
type="number"
step="0.5"
value={manualEstimate}
onChange={(e) => setManualEstimate(e.target.value)}
placeholder="Override"
className="flex-1 border border-slate-300 dark:border-slate-600 rounded-sm px-3 py-2 text-sm bg-white dark:bg-slate-800 dark:text-slate-100"
/>
<button
onClick={handleSave}
className="bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-sm px-6 py-2 transition-colors cursor-pointer border-none"
>
Save Estimate
</button>
</div>
</div>
) : (
<div className="grid grid-cols-7 gap-2">
{CARDS.map((card) => {
const isSelected = myVote === card.value;
return (
<button
key={card.value}
onClick={() => handleVote(card.value)}
className="aspect-[2/3] rounded-sm border-2 transition-all flex flex-col items-center justify-center gap-1 hover:scale-105 cursor-pointer bg-transparent"
style={{
borderColor: isSelected ? card.color : 'var(--card-border, #e2e8f0)',
backgroundColor: isSelected ? `${card.color}15` : 'transparent'
}}
>
<span
className="text-base font-bold"
style={{ color: isSelected ? card.color : 'var(--card-text, #475569)' }}
>
{card.label}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
{/* Participants */}
<div className="flex-1 flex flex-col overflow-y-auto">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h2 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider m-0">
Participants
</h2>
</div>
<div className="flex-1 p-4 bg-slate-50 dark:bg-slate-800/50">
<div className="flex flex-col gap-2">
{participants.map((participant) => {
const hasVoted = revealed
? votes[participant.userKey] !== undefined
: votedUserKeys.includes(participant.userKey);
const vote = revealed ? votes[participant.userKey] : null;
const isCurrentUser = participant.userKey === user.key;
return (
<div
key={participant.userKey}
className="flex items-center justify-between p-2 border border-slate-200 dark:border-slate-700 rounded-sm bg-white dark:bg-slate-800"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="w-7 h-7 rounded-full bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 flex items-center justify-center text-xs font-semibold shrink-0">
{participant.userName?.charAt(0)?.toUpperCase() || '?'}
</div>
<span className="text-sm text-slate-900 dark:text-slate-100 truncate">
{participant.userName}
{isCurrentUser && <span className="text-slate-400"> (you)</span>}
</span>
</div>
<div className="text-sm shrink-0 ml-2">
{revealed && vote !== null && vote !== undefined ? (
<span className="font-semibold text-purple-600 dark:text-purple-400">{vote}</span>
) : hasVoted ? (
<span className="text-green-600 dark:text-green-400"></span>
) : (
<span className="text-slate-400">...</span>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
{error && (
<div className="px-4 py-2 bg-red-50 dark:bg-red-900/20 border-t border-red-200 dark:border-red-800">
<p className="text-red-700 dark:text-red-400 text-sm m-0">{error}</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,210 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../services/api';
import PokerRoom from './PokerRoom';
import DarkModeToggle from './DarkModeToggle';
import AdfRenderer from './AdfRenderer';
export default function Room({ room, user, dark, toggleDark, onBack }) {
const [issues, setIssues] = useState([]);
const [activeSession, setActiveSession] = useState(null);
const [done, setDone] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const issuesRef = useRef([]);
const pokerUser = { key: user.jiraAccountId, name: user.displayName };
useEffect(() => {
loadIssues();
}, [room.sprintId]);
async function loadIssues() {
setLoading(true);
try {
const { issues: sprintIssues } = await api.getSprintIssues(room.sprintId, room.boardId);
const list = sprintIssues || [];
setIssues(list);
issuesRef.current = list;
startFirstUnestimated(list);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
function startFirstUnestimated(issueList) {
const issue = issueList.find((i) => !i.estimate || i.estimate === 0);
if (!issue) {
finishSession();
return;
}
startSessionForIssue(issue);
}
async function startSessionForIssue(issue) {
setError('');
try {
const { session } = await api.startSession({
issueKey: issue.key,
issueId: issue.id,
issueTitle: issue.title,
roomId: room.id,
boardId: room.boardId
});
setActiveSession({ session, issue });
} catch (err) {
setError(err.message);
}
}
async function finishSession() {
setDone(true);
try {
await api.deleteRoom(room.id);
} catch {
// best-effort cleanup
}
}
const activeSessionRef = useRef(null);
activeSessionRef.current = activeSession;
const advanceToNext = useCallback((estimate) => {
if (!activeSessionRef.current) return;
const currentKey = activeSessionRef.current.issue.key;
const updated = issuesRef.current.map((i) =>
i.key === currentKey ? { ...i, estimate } : i
);
issuesRef.current = updated;
setIssues(updated);
setActiveSession(null);
const next = updated.find((i) => !i.estimate || i.estimate === 0);
if (!next) {
finishSession();
return;
}
setTimeout(() => startSessionForIssue(next), 300);
}, []);
const estimatedCount = issues.filter((i) => i.estimate && i.estimate > 0).length;
const totalCount = issues.length;
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center dark:bg-slate-900">
<p className="text-slate-400 text-sm">Loading sprint issues...</p>
</div>
);
}
if (done) {
return (
<div className="min-h-screen flex items-center justify-center p-4 dark:bg-slate-900">
<div className="max-w-xl w-full text-center flex flex-col gap-4 items-center animate-fade-up">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 m-0">Session Complete</h1>
<p className="text-slate-500 dark:text-slate-400 m-0">
{estimatedCount} of {totalCount} issues estimated
</p>
<ul className="w-full text-left list-none m-0 p-0 flex flex-col gap-2">
{issues.map((issue) => (
<li
key={issue.key}
className="border border-slate-200 dark:border-slate-700 p-3 flex justify-between gap-3 bg-white dark:bg-slate-800"
>
<div className="min-w-0">
<strong className="text-sm dark:text-slate-100">{issue.key}</strong>
<p className="text-sm text-slate-600 dark:text-slate-400 m-0 mt-0.5 truncate">{issue.title}</p>
</div>
<span className="font-bold text-purple-600 dark:text-purple-400 shrink-0">
{issue.estimate || 0} pts
</span>
</li>
))}
</ul>
<button
onClick={onBack}
className="bg-emerald-500 hover:bg-emerald-400 text-white font-semibold px-6 py-3 transition-colors cursor-pointer border-none text-sm tracking-wide"
>
Back to Lobby
</button>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col dark:bg-slate-900">
{/* Header */}
<header className="flex justify-between items-center bg-slate-900 dark:bg-slate-950 text-white px-5 py-2 shrink-0">
<div className="flex items-center gap-3">
<span className="font-syne font-bold text-xs text-slate-500 tracking-tight">POKERFACE</span>
<span className="text-slate-600">/</span>
<strong className="text-sm">{room.projectName || room.projectKey}</strong>
<span className="text-slate-600">&mdash;</span>
<span className="text-slate-400 text-sm">{room.sprintName}</span>
</div>
<div className="flex items-center gap-4">
<DarkModeToggle dark={dark} toggleDark={toggleDark} />
<span className="text-slate-400 text-sm">{estimatedCount}/{totalCount} estimated</span>
<button
onClick={onBack}
className="bg-slate-700 hover:bg-slate-600 text-white text-xs font-medium px-3 py-1 transition-colors cursor-pointer border-none"
>
Leave Room
</button>
</div>
</header>
{error && (
<div className="px-4 py-2 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800">
<p className="text-red-700 dark:text-red-400 text-sm m-0">{error}</p>
</div>
)}
{/* Split layout */}
{activeSession && (
<div className="flex-1 flex min-h-0">
{/* Left: Poker cards + participants */}
<div className="flex-1 flex flex-col border-r border-slate-200 dark:border-slate-700 overflow-y-auto bg-white dark:bg-slate-900">
<PokerRoom
key={activeSession.session.id}
session={activeSession.session}
issue={activeSession.issue}
user={pokerUser}
onSaved={advanceToNext}
/>
</div>
{/* Right: Issue details */}
<div className="flex-1 overflow-y-auto bg-slate-50 dark:bg-slate-800/50">
<div className="bg-slate-100 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-4 py-3">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{activeSession.issue.key}
</div>
</div>
<div className="px-4 py-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100 leading-tight m-0">
{activeSession.issue.title}
</h3>
</div>
{activeSession.issue.description && (
<div className="p-4 text-slate-700 dark:text-slate-300">
<AdfRenderer document={activeSession.issue.description} />
</div>
)}
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700">
<div className="flex gap-4 text-xs text-slate-500 dark:text-slate-400">
<span>Reporter: {activeSession.issue.reporter || 'Unknown'}</span>
<span>Status: {activeSession.issue.status}</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react';
export default function SearchSelect({ options, value, onChange, placeholder }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [highlightIndex, setHighlightIndex] = useState(0);
const containerRef = useRef(null);
const inputRef = useRef(null);
const filtered = options.filter((o) =>
o.label.toLowerCase().includes(query.toLowerCase())
);
const selectedLabel = options.find((o) => String(o.value) === String(value))?.label || '';
useEffect(() => {
function handleClickOutside(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
setHighlightIndex(0);
}, [query]);
function handleOpen() {
setOpen(true);
setQuery('');
setTimeout(() => inputRef.current?.focus(), 0);
}
function selectOption(opt) {
onChange(opt.value);
setOpen(false);
setQuery('');
}
function handleKeyDown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (filtered[highlightIndex]) selectOption(filtered[highlightIndex]);
} else if (e.key === 'Escape') {
setOpen(false);
}
}
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={handleOpen}
className="w-full border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-left cursor-pointer flex justify-between items-center px-3 py-2 text-sm transition-colors hover:border-slate-400 dark:hover:border-slate-500"
>
<span className={value ? 'text-slate-900 dark:text-slate-100' : 'text-slate-400'}>
{selectedLabel || placeholder || 'Select...'}
</span>
<svg className="w-4 h-4 text-slate-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="square" strokeLinejoin="miter" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 shadow-xl shadow-black/8 max-h-60 flex flex-col">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="border-b border-slate-200 dark:border-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-400 outline-none bg-transparent"
/>
<div className="overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-3 py-2 text-sm text-slate-400">No matches</div>
) : (
filtered.map((opt, i) => (
<div
key={opt.value}
onClick={() => selectOption(opt)}
onMouseEnter={() => setHighlightIndex(i)}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
i === highlightIndex
? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
} ${String(opt.value) === String(value) ? 'font-semibold' : ''}`}
>
{opt.label}
</div>
))
)}
</div>
</div>
)}
</div>
);
}

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);

View file

@ -0,0 +1,55 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('UNAUTHORIZED');
}
const text = await response.text();
throw new Error(text || `Request failed: ${response.status}`);
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return response.json();
}
return null;
}
export const api = {
getMe: () => request('/jira/me'),
logout: () => request('/jira/logout', { method: 'POST' }),
startJiraOAuth: () => request('/jira/oauth/start'),
getProjects: () => request('/jira/projects'),
getBoards: (projectKeyOrId) => request(`/jira/boards?projectKeyOrId=${encodeURIComponent(projectKeyOrId)}`),
getBoardSprints: (boardId) => request(`/jira/boards/${encodeURIComponent(boardId)}/sprints`),
getSprintIssues: (sprintId, boardId) => request(`/jira/sprints/${encodeURIComponent(sprintId)}/issues${boardId ? `?boardId=${encodeURIComponent(boardId)}` : ''}`),
createRoom: (payload) =>
request('/poker/rooms', {
method: 'POST',
body: JSON.stringify(payload)
}),
getRooms: () => request('/poker/rooms'),
getRoom: (roomId) => request(`/poker/rooms/${encodeURIComponent(roomId)}`),
deleteRoom: (roomId) =>
request(`/poker/rooms/${encodeURIComponent(roomId)}`, { method: 'DELETE' }),
startSession: (payload) =>
request('/poker/sessions', {
method: 'POST',
body: JSON.stringify(payload)
}),
getSession: (sessionId) => request(`/poker/sessions/${encodeURIComponent(sessionId)}`)
};
// When using same-origin (default), socketBaseUrl is undefined so io() connects to the current host
const explicit = import.meta.env.VITE_API_URL;
export const socketBaseUrl = explicit ? explicit.replace(/\/api$/, '') : undefined;

View file

@ -0,0 +1,25 @@
import { io } from 'socket.io-client';
import { socketBaseUrl } from './api';
let socket;
export function getSocket() {
if (!socket) {
const opts = {
autoConnect: true,
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 750
};
if (socketBaseUrl) {
opts.withCredentials = true;
socket = io(socketBaseUrl, opts);
} else {
socket = io(opts);
}
}
return socket;
}

58
frontend/src/styles.css Normal file
View file

@ -0,0 +1,58 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Syne:wght@700;800&display=swap');
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-syne: 'Syne', sans-serif;
}
@layer base {
body {
font-family: 'Outfit', sans-serif;
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #0f172a;
background: #f0f1f5;
background-image:
radial-gradient(ellipse at 20% 0%, rgba(16, 185, 129, 0.045) 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(99, 102, 241, 0.03) 0%, transparent 50%);
transition: background-color 0.2s, color 0.2s;
}
:root {
--card-border: #e2e8f0;
--card-text: #475569;
}
.dark {
--card-border: #334155;
--card-text: #94a3b8;
}
.dark body {
color: #e2e8f0;
background: #0f172a;
background-image:
radial-gradient(ellipse at 20% 0%, rgba(16, 185, 129, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(99, 102, 241, 0.04) 0%, transparent 50%);
}
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@utility animate-fade-up {
animation: fade-up 0.5s ease-out both;
}
@utility animate-fade-in {
animation: fade-in 0.35s ease-out both;
}

22
frontend/vite.config.js Normal file
View file

@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5174,
allowedHosts: true,
proxy: {
'/api': {
target: 'http://localhost:4010',
changeOrigin: true
},
'/socket.io': {
target: 'http://localhost:4010',
changeOrigin: true,
ws: true
}
}
}
});