Skip to content

Commit a843293

Browse files
mariuspruvotclaude
andcommitted
feat(web): add PR-comment toggle, back link, chart tooltip
- SettingsView: toggle for post_results_to_pr (calls PUT /post-results) - SkillSelector: back link to installation detail in top-left - ActivityChart: custom styled hover tooltip replacing native title Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 708e52c commit a843293

4 files changed

Lines changed: 116 additions & 14 deletions

File tree

apps/web/src/features/dashboard/ActivityChart.tsx

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* 30 bars with 3px gap, 120px height, accent color with active highlight.
44
*/
55

6+
import { useState } from 'react'
7+
68
interface DailyCount {
79
date: string
810
count: number
@@ -26,33 +28,57 @@ function fillToThirtyDays(data: DailyCount[]): DailyCount[] {
2628
return result
2729
}
2830

31+
function formatDate(iso: string): string {
32+
const d = new Date(`${iso}T00:00:00Z`)
33+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
34+
}
35+
2936
export default function ActivityChart({ data }: ActivityChartProps) {
3037
const days = fillToThirtyDays(data)
3138
const maxCount = Math.max(...days.map((d) => d.count), 1)
39+
const [hoverIdx, setHoverIdx] = useState<number | null>(null)
3240

3341
return (
3442
<div>
35-
<div className="flex items-end gap-[3px]" style={{ height: 120 }}>
43+
<div className="relative flex items-end gap-[3px]" style={{ height: 120 }}>
3644
{days.map((d, i) => {
3745
const heightPx = d.count === 0 ? 2 : Math.max(4, (d.count / maxCount) * 110)
3846
const isRecent = i >= 23
3947
const isEmpty = d.count === 0
48+
const isHovered = hoverIdx === i
4049

4150
return (
4251
<div
4352
key={d.date}
44-
className="flex-1 rounded-[2px] transition-opacity hover:opacity-100"
53+
onMouseEnter={() => setHoverIdx(i)}
54+
onMouseLeave={() => setHoverIdx((prev) => (prev === i ? null : prev))}
55+
className="relative flex-1 rounded-[2px] transition-all"
4556
style={{
4657
height: heightPx,
4758
backgroundColor: isEmpty
4859
? 'var(--color-rule)'
4960
: isRecent
5061
? 'var(--color-accent)'
5162
: 'rgba(226,160,57,0.35)',
52-
opacity: isEmpty ? 0.5 : isRecent ? 1 : 0.85,
63+
opacity: isHovered ? 1 : isEmpty ? 0.5 : isRecent ? 1 : 0.85,
64+
outline: isHovered ? '1px solid var(--color-accent)' : 'none',
65+
outlineOffset: 1,
5366
}}
54-
title={`${d.date}: ${d.count} session${d.count !== 1 ? 's' : ''}`}
55-
/>
67+
>
68+
{isHovered && (
69+
<div
70+
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 rounded-default bg-bg2 border border-rule-str shadow-card whitespace-nowrap pointer-events-none z-10"
71+
role="tooltip"
72+
>
73+
<div className="font-mono text-[11px] text-ink font-semibold leading-tight">
74+
{d.count} session{d.count !== 1 ? 's' : ''}
75+
</div>
76+
<div className="font-mono text-[10px] text-dim leading-tight">
77+
{formatDate(d.date)}
78+
</div>
79+
</div>
80+
)}
81+
</div>
5682
)
5783
})}
5884
</div>

apps/web/src/features/installation/SettingsView.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface InstallationDetail {
1414
byok_key_status: string | null
1515
byok_validated_at: string | null
1616
suppression_labels: string[]
17+
post_results_to_pr: boolean
1718
}
1819

1920
const GITHUB_APP_SLUG = import.meta.env.VITE_GITHUB_APP_SLUG ?? 'helprs'
@@ -35,6 +36,10 @@ export default function SettingsView() {
3536
const [labelsLoading, setLabelsLoading] = useState(false)
3637
const [labelsError, setLabelsError] = useState<string | null>(null)
3738

39+
const [postResults, setPostResults] = useState(false)
40+
const [postResultsLoading, setPostResultsLoading] = useState(false)
41+
const [postResultsError, setPostResultsError] = useState<string | null>(null)
42+
3843
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
3944

4045
const fetchInstallation = useCallback(async () => {
@@ -48,6 +53,7 @@ export default function SettingsView() {
4853
const data = await resp.json() as InstallationDetail
4954
setInstallation(data)
5055
setLabels(data.suppression_labels)
56+
setPostResults(data.post_results_to_pr)
5157
} finally {
5258
setLoading(false)
5359
}
@@ -148,6 +154,27 @@ export default function SettingsView() {
148154
saveLabels(updated)
149155
}
150156

157+
const togglePostResults = async (enabled: boolean) => {
158+
const previous = postResults
159+
setPostResults(enabled)
160+
setPostResultsLoading(true)
161+
setPostResultsError(null)
162+
try {
163+
const resp = await apiFetch(`/api/v1/installations/${installationId}/post-results`, {
164+
method: 'PUT',
165+
headers: { 'Content-Type': 'application/json' },
166+
body: JSON.stringify({ post_results_to_pr: enabled }),
167+
})
168+
if (!resp.ok) {
169+
const data = await resp.json() as { message?: string }
170+
setPostResultsError(data.message ?? 'Failed to update setting')
171+
setPostResults(previous)
172+
}
173+
} finally {
174+
setPostResultsLoading(false)
175+
}
176+
}
177+
151178
if (loading) {
152179
return (
153180
<div className="flex items-center justify-center py-20">
@@ -334,6 +361,38 @@ export default function SettingsView() {
334361
</a>
335362
</Card>
336363

364+
{/* PR Integration */}
365+
<Card className="mb-6">
366+
<Overline className="mb-4">PR Integration</Overline>
367+
<label className="flex items-start gap-3 cursor-pointer select-none">
368+
<button
369+
type="button"
370+
role="switch"
371+
aria-checked={postResults}
372+
onClick={() => togglePostResults(!postResults)}
373+
disabled={postResultsLoading}
374+
className={`relative mt-0.5 shrink-0 w-9 h-5 rounded-full transition-colors cursor-pointer disabled:opacity-50 ${
375+
postResults ? 'bg-accent' : 'bg-rule-str'
376+
}`}
377+
>
378+
<span
379+
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-bg transition-transform ${
380+
postResults ? 'translate-x-4' : 'translate-x-0'
381+
}`}
382+
/>
383+
</button>
384+
<div>
385+
<p className="text-ink text-sm font-sans">Post results to PR</p>
386+
<p className="text-dim text-xs font-mono mt-0.5">
387+
// after each challenge-me session, post the scorecard as a PR comment
388+
</p>
389+
</div>
390+
</label>
391+
{postResultsError && (
392+
<p className="text-danger text-sm mt-3">{postResultsError}</p>
393+
)}
394+
</Card>
395+
337396
{/* Danger Zone */}
338397
<Card className="border-danger/20">
339398
<Overline className="mb-4 text-danger">Danger Zone</Overline>

apps/web/src/features/session/SkillSelector.test.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { cleanup, render, screen, fireEvent } from '@testing-library/react'
22
import { afterEach, describe, expect, test, vi } from 'vitest'
3+
import { MemoryRouter } from 'react-router'
34
import SkillSelector, { SKILLS } from './SkillSelector'
45

6+
function renderWithRouter(ui: React.ReactElement) {
7+
return render(<MemoryRouter>{ui}</MemoryRouter>)
8+
}
9+
510
afterEach(() => {
611
cleanup()
712
})
@@ -14,7 +19,7 @@ describe('SkillSelector', () => {
1419
}
1520

1621
test('renders all skill cards', () => {
17-
render(<SkillSelector {...defaultProps} />)
22+
renderWithRouter(<SkillSelector {...defaultProps} />)
1823

1924
for (const skill of SKILLS) {
2025
expect(screen.getByTestId(`skill-card-${skill.name}`)).toBeTruthy()
@@ -24,7 +29,7 @@ describe('SkillSelector', () => {
2429
})
2530

2631
test('renders repo and PR info in header', () => {
27-
render(<SkillSelector {...defaultProps} />)
32+
renderWithRouter(<SkillSelector {...defaultProps} />)
2833

2934
expect(screen.getByTestId('skill-selector')).toBeTruthy()
3035
const repoElements = screen.getAllByText('acme/helprs')
@@ -35,15 +40,15 @@ describe('SkillSelector', () => {
3540

3641
test('calls onSelectSkill when challenge-me card is clicked', () => {
3742
const onSelectSkill = vi.fn()
38-
render(<SkillSelector {...defaultProps} onSelectSkill={onSelectSkill} />)
43+
renderWithRouter(<SkillSelector {...defaultProps} onSelectSkill={onSelectSkill} />)
3944

4045
fireEvent.click(screen.getByTestId('skill-card-challenge-me'))
4146
expect(onSelectSkill).toHaveBeenCalledWith('challenge-me')
4247
})
4348

4449
test('does not call onSelectSkill for coming-soon skills', () => {
4550
const onSelectSkill = vi.fn()
46-
render(<SkillSelector {...defaultProps} onSelectSkill={onSelectSkill} />)
51+
renderWithRouter(<SkillSelector {...defaultProps} onSelectSkill={onSelectSkill} />)
4752

4853
fireEvent.click(screen.getByTestId('skill-card-eli5'))
4954
fireEvent.click(screen.getByTestId('skill-card-pair-debug'))
@@ -53,19 +58,19 @@ describe('SkillSelector', () => {
5358
})
5459

5560
test('shows "soon" badge for unreleased skills', () => {
56-
render(<SkillSelector {...defaultProps} />)
61+
renderWithRouter(<SkillSelector {...defaultProps} />)
5762

5863
const badges = screen.getAllByText('soon')
5964
expect(badges.length).toBe(4)
6065
})
6166

6267
test('shows DEFAULT badge for challenge-me', () => {
63-
render(<SkillSelector {...defaultProps} />)
68+
renderWithRouter(<SkillSelector {...defaultProps} />)
6469
expect(screen.getByText('DEFAULT')).toBeTruthy()
6570
})
6671

6772
test('disables cards when disabled prop is true', () => {
68-
render(<SkillSelector {...defaultProps} disabled={true} />)
73+
renderWithRouter(<SkillSelector {...defaultProps} disabled={true} />)
6974

7075
for (const skill of SKILLS) {
7176
const card = screen.getByTestId(`skill-card-${skill.name}`) as HTMLButtonElement
@@ -75,14 +80,14 @@ describe('SkillSelector', () => {
7580

7681
test('does not call onSelectSkill when disabled', () => {
7782
const onSelectSkill = vi.fn()
78-
render(<SkillSelector {...defaultProps} onSelectSkill={onSelectSkill} disabled={true} />)
83+
renderWithRouter(<SkillSelector {...defaultProps} onSelectSkill={onSelectSkill} disabled={true} />)
7984

8085
fireEvent.click(screen.getByTestId('skill-card-challenge-me'))
8186
expect(onSelectSkill).not.toHaveBeenCalled()
8287
})
8388

8489
test('renders helPRs branding', () => {
85-
render(<SkillSelector {...defaultProps} />)
90+
renderWithRouter(<SkillSelector {...defaultProps} />)
8691
const brandElements = screen.getAllByText('helPRs')
8792
expect(brandElements.length).toBeGreaterThan(0)
8893
})

apps/web/src/features/session/SkillSelector.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Skills per spec: challenge-me (active), eli5/pair-debug/hot-seat/test-me (coming soon).
44
*/
55

6+
import { Link, useParams } from 'react-router'
67
import { Card, Chip, Overline } from '../../shared/components'
78
import type { Skill } from './containerTypes'
89

@@ -58,11 +59,22 @@ export default function SkillSelector({
5859
onSelectSkill,
5960
disabled = false,
6061
}: SkillSelectorProps) {
62+
const { installationId } = useParams<{ installationId: string }>()
63+
const installationUrl = installationId ? `/installations/${installationId}` : '/installations'
64+
6165
return (
6266
<div
6367
data-testid="skill-selector"
6468
className="min-h-screen bg-bg text-ink font-sans flex flex-col items-center justify-center px-6 py-12"
6569
>
70+
{/* Back to installation */}
71+
<Link
72+
to={installationUrl}
73+
className="absolute top-5 left-5 font-mono text-xs text-dim hover:text-ink2 transition-colors"
74+
>
75+
{'\u2190'} installation
76+
</Link>
77+
6678
{/* Header */}
6779
<div className="text-center mb-10 max-w-lg">
6880
<h1 className="font-mono text-lg font-bold text-accent mb-2">helPRs</h1>

0 commit comments

Comments
 (0)