Skip to content

Commit 9836cf3

Browse files
jdamcdclaude
andcommitted
Add stats panel, reorganise controls, update empty state
- Add trips-per-year chart and continent coverage stats panel (recharts) - Move date range filter and controls into a unified toolbar - Extract useDarkMode hook and countryCodeToFlag utility to shared modules - Add fly-to-home on home label click - Update visit list empty state copy - Update screenshots and Playwright snapshots Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent de5b38a commit 9836cf3

15 files changed

Lines changed: 888 additions & 83 deletions
-4.62 KB
Loading
-20 KB
Loading

package-lock.json

Lines changed: 403 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"react-dom": "^19.2.0",
2323
"react-map-gl": "^8.1.0",
2424
"react-markdown": "^10.1.0",
25+
"recharts": "^3.7.0",
2526
"uuid": "^13.0.0"
2627
},
2728
"devDependencies": {

screenshot.png

-17.8 KB
Loading

src/App.tsx

Lines changed: 70 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { WorldMap } from './components/WorldMap';
55
import { VisitList } from './components/VisitList';
66
import { CalendarInput } from './components/CalendarInput';
77
import { AddVisitForm } from './components/AddVisitForm';
8+
import { StatsPanel } from './components/StatsPanel';
9+
import { DateRangeFilter } from './components/DateRangeFilter';
810
import { LegalPage } from './components/LegalPage';
911
import {
1012
saveVisits,
@@ -36,10 +38,18 @@ function App() {
3638
});
3739
const [showAddForm, setShowAddForm] = useState(false);
3840
const [showCalendarInput, setShowCalendarInput] = useState(false);
41+
const [showStats, setShowStats] = useState(false);
3942
const [highlightedCountry, setHighlightedCountry] = useState<string>();
4043
const [homeCountry, setHomeCountry] = useState<string>(() => loadHomeCountry());
44+
const [flyHomeCounter, setFlyHomeCounter] = useState(0);
4145
const [page, setPage] = useState<Page>(() => parseHash(window.location.hash));
4246

47+
const statsAvailable = dateRange.end.getFullYear() - dateRange.start.getFullYear() >= 2;
48+
49+
useEffect(() => {
50+
if (!statsAvailable) setShowStats(false);
51+
}, [statsAvailable]);
52+
4353
// Listen for hash changes
4454
useEffect(() => {
4555
const onHashChange = () => setPage(parseHash(window.location.hash));
@@ -180,7 +190,7 @@ function App() {
180190
<div className="max-w-7xl mx-auto flex items-center justify-between">
181191
<h1 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white">tripm.app</h1>
182192
<p className="text-xs md:text-sm text-gray-500 dark:text-gray-400">
183-
Extract travel history from your calendar
193+
Visualise your travel history
184194
</p>
185195
</div>
186196
</header>
@@ -190,49 +200,64 @@ function App() {
190200
) : (
191201
<main className="flex-1 max-w-7xl mx-auto w-full p-4 space-y-4">
192202
{/* Controls */}
193-
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
194-
<div className="flex flex-wrap items-center justify-between gap-4">
195-
<div className="flex flex-wrap gap-2">
196-
<button
197-
onClick={() => setShowCalendarInput(!showCalendarInput)}
198-
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
199-
>
200-
{showCalendarInput ? 'Cancel' : 'Import calendar'}
201-
</button>
202-
<button
203-
onClick={() => setShowAddForm(!showAddForm)}
204-
className="px-4 py-2 bg-emerald-600 text-white rounded-md hover:bg-emerald-700 text-sm"
205-
>
206-
{showAddForm ? 'Cancel' : 'Add trip manually'}
207-
</button>
203+
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
204+
{/* Row 1: Action buttons */}
205+
<div className="flex flex-wrap gap-2 p-4">
206+
<button
207+
onClick={() => setShowCalendarInput(!showCalendarInput)}
208+
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
209+
>
210+
{showCalendarInput ? 'Cancel' : 'Import calendar'}
211+
</button>
212+
<button
213+
onClick={() => setShowAddForm(!showAddForm)}
214+
className="px-4 py-2 bg-emerald-600 text-white rounded-md hover:bg-emerald-700 text-sm"
215+
>
216+
{showAddForm ? 'Cancel' : 'Add trip'}
217+
</button>
218+
<button
219+
onClick={() => setShowStats(!showStats)}
220+
disabled={!statsAvailable || visits.length === 0}
221+
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm disabled:opacity-40"
222+
>
223+
{showStats ? 'Hide stats' : 'Stats'}
224+
</button>
225+
<button
226+
onClick={handleExport}
227+
disabled={visits.length === 0}
228+
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm disabled:opacity-40"
229+
>
230+
Export
231+
</button>
232+
<label className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm cursor-pointer">
233+
Import
234+
<input
235+
type="file"
236+
accept=".json"
237+
onChange={handleImportJson}
238+
className="hidden"
239+
/>
240+
</label>
241+
{visits.length > 0 && (
208242
<button
209-
onClick={handleExport}
210-
disabled={visits.length === 0}
211-
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm disabled:bg-gray-300 dark:disabled:bg-gray-600"
243+
onClick={handleClearAll}
244+
className="px-4 py-2 bg-red-700 text-white rounded-md hover:bg-red-800 text-sm"
212245
>
213-
Export
246+
Reset
214247
</button>
215-
<label className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm cursor-pointer">
216-
Import
217-
<input
218-
type="file"
219-
accept=".json"
220-
onChange={handleImportJson}
221-
className="hidden"
222-
/>
223-
</label>
224-
{visits.length > 0 && (
225-
<button
226-
onClick={handleClearAll}
227-
className="px-4 py-2 bg-red-700 text-white rounded-md hover:bg-red-800 text-sm"
228-
>
229-
Reset
230-
</button>
231-
)}
232-
</div>
248+
)}
249+
</div>
250+
251+
{/* Row 2: Period filter, home selector, stats */}
252+
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-gray-200 dark:border-gray-700 px-4 py-3">
253+
<DateRangeFilter dateRange={dateRange} onChange={setDateRange} />
233254
<div className="flex items-center gap-4">
234255
<div className="flex items-center gap-2">
235-
<label htmlFor="home-country" className="text-sm font-medium text-gray-700 dark:text-gray-300">
256+
<label
257+
htmlFor="home-country"
258+
className={`text-sm text-gray-700 dark:text-gray-300 ${homeCountry ? 'cursor-pointer hover:text-gray-900 dark:hover:text-white' : ''}`}
259+
onClick={() => { if (homeCountry) setFlyHomeCounter((c) => c + 1); }}
260+
>
236261
Home
237262
</label>
238263
<div className="relative">
@@ -287,14 +312,17 @@ function App() {
287312
/>
288313
)}
289314

315+
{/* Stats panel */}
316+
{showStats && (
317+
<StatsPanel visits={filteredVisits} />
318+
)}
319+
290320
{/* List and map */}
291321
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:h-[600px]">
292322
{/* Visit list */}
293323
<div className="h-[400px] lg:h-auto bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
294324
<VisitList
295325
visits={filteredVisits}
296-
dateRange={dateRange}
297-
onDateRangeChange={setDateRange}
298326
onDeleteVisit={handleDeleteEntry}
299327
onDeleteCountry={handleDeleteCountry}
300328
onEditEntry={handleEditEntry}
@@ -307,6 +335,7 @@ function App() {
307335
<WorldMap
308336
visits={visitsInDateRange}
309337
homeCountry={homeCountry}
338+
flyHomeTrigger={flyHomeCounter}
310339
onCountryClick={setHighlightedCountry}
311340
/>
312341
</div>

src/components/DateRangeFilter.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,6 @@ export function DateRangeFilter({ dateRange, onChange }: DateRangeFilterProps) {
8080
return (
8181
<div className="flex flex-wrap items-center gap-2">
8282
<div className="flex items-center gap-2">
83-
<span className="w-12 shrink-0 text-sm text-gray-500 dark:text-gray-400">Period</span>
84-
8583
<div className="flex flex-wrap gap-2">
8684
{presets.map(({ value, label, shortLabel }) => (
8785
<button
@@ -101,7 +99,7 @@ export function DateRangeFilter({ dateRange, onChange }: DateRangeFilterProps) {
10199
</div>
102100

103101
{showCustom && (
104-
<div className="flex items-center gap-2 ml-14">
102+
<div className="flex items-center gap-2">
105103
<input
106104
type="date"
107105
value={format(dateRange.start, 'yyyy-MM-dd')}

src/components/StatsPanel.tsx

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { useMemo } from 'react';
2+
import {
3+
LineChart,
4+
Line,
5+
XAxis,
6+
YAxis,
7+
CartesianGrid,
8+
Tooltip,
9+
ResponsiveContainer,
10+
} from 'recharts';
11+
import type { CountryVisit } from '../types';
12+
import { tripsPerYear, continentCoverage, type YearTrips } from '../lib/stats';
13+
import { countryCodeToFlag } from '../lib/format';
14+
import { useDarkMode } from '../lib/useDarkMode';
15+
16+
interface StatsPanelProps {
17+
visits: CountryVisit[];
18+
}
19+
20+
export function StatsPanel({ visits }: StatsPanelProps) {
21+
const isDark = useDarkMode();
22+
23+
const yearData = useMemo(() => tripsPerYear(visits), [visits]);
24+
const continentData = useMemo(() => continentCoverage(visits), [visits]);
25+
26+
if (visits.length === 0) {
27+
return (
28+
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center text-gray-500 dark:text-gray-400">
29+
No trip data to show
30+
</div>
31+
);
32+
}
33+
34+
const axisColor = isDark ? '#9ca3af' : '#6b7280';
35+
const gridColor = isDark ? '#374151' : '#e5e7eb';
36+
const tooltipBg = isDark ? '#1f2937' : '#ffffff';
37+
const tooltipBorder = isDark ? '#374151' : '#e5e7eb';
38+
const lineColor = '#3b82f6';
39+
40+
return (
41+
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-2">
42+
{/* Trips per year */}
43+
{yearData.length > 0 && (
44+
<div>
45+
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
46+
Trips per year
47+
</h3>
48+
<div className="h-40 [&_.recharts-surface]:outline-none">
49+
<ResponsiveContainer width="100%" height="100%">
50+
<LineChart data={yearData} margin={{ top: 8, right: 8, left: -20 }}>
51+
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
52+
<XAxis
53+
dataKey="year"
54+
tick={{ fill: axisColor, fontSize: 12 }}
55+
tickLine={{ stroke: axisColor }}
56+
axisLine={{ stroke: gridColor }}
57+
/>
58+
<YAxis
59+
allowDecimals={false}
60+
domain={[0, (max: number) => max]}
61+
tick={{ fill: axisColor, fontSize: 12 }}
62+
tickLine={{ stroke: axisColor }}
63+
axisLine={{ stroke: gridColor }}
64+
tickFormatter={(value: number) => (value === 0 ? '' : String(value))}
65+
/>
66+
<Tooltip
67+
content={({ active, payload }) => {
68+
if (!active || !payload?.length) return null;
69+
const data = payload[0].payload as YearTrips;
70+
return (
71+
<div
72+
style={{
73+
backgroundColor: tooltipBg,
74+
border: `1px solid ${tooltipBorder}`,
75+
borderRadius: '0.375rem',
76+
color: isDark ? '#f3f4f6' : '#111827',
77+
padding: '0.5rem 0.75rem',
78+
fontSize: '0.75rem',
79+
}}
80+
>
81+
<div style={{ fontWeight: 600, marginBottom: '0.25rem' }}>
82+
{data.year}{data.trips} {data.trips === 1 ? 'trip' : 'trips'}
83+
</div>
84+
<div>
85+
{data.countries.map((c) => (
86+
<div key={c.code}>
87+
{c.name}{c.count > 1 ? ` x${c.count}` : ''}
88+
</div>
89+
))}
90+
</div>
91+
</div>
92+
);
93+
}}
94+
/>
95+
<Line
96+
type="monotone"
97+
dataKey="trips"
98+
stroke={lineColor}
99+
strokeWidth={2}
100+
dot={{ fill: lineColor, r: 3 }}
101+
activeDot={{ r: 5 }}
102+
/>
103+
</LineChart>
104+
</ResponsiveContainer>
105+
</div>
106+
</div>
107+
)}
108+
109+
{/* Continents */}
110+
{continentData.length > 0 && (
111+
<div>
112+
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
113+
Continents
114+
</h3>
115+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
116+
{continentData.map((c) => (
117+
<div
118+
key={c.continent}
119+
className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3"
120+
>
121+
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
122+
{c.continent}
123+
</div>
124+
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
125+
{c.visited} {c.visited === 1 ? 'country' : 'countries'}, {c.trips}{' '}
126+
{c.trips === 1 ? 'trip' : 'trips'}
127+
</div>
128+
<div className="mt-1.5 leading-relaxed">
129+
{c.countryCodes.map((code) => (
130+
<span key={code} title={code} className="text-sm">
131+
{countryCodeToFlag(code)}
132+
</span>
133+
))}
134+
</div>
135+
</div>
136+
))}
137+
</div>
138+
</div>
139+
)}
140+
</div>
141+
);
142+
}

0 commit comments

Comments
 (0)