Skip to content

Commit 725a04a

Browse files
Copilotpatrickrb
andauthored
Enhancement: Show Previous Contacts on Contact Page + Fix Test Failures (#134)
* Initial plan * Implement previous contacts feature on new contact page Co-authored-by: patrickrb <[email protected]> * Add demo page and finalize previous contacts feature Co-authored-by: patrickrb <[email protected]> * Fix frequency type error in PreviousContacts component Co-authored-by: patrickrb <[email protected]> * Fix test failures by handling null response objects in navigation redirects Co-authored-by: patrickrb <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: patrickrb <[email protected]> Co-authored-by: Patrick Burns <[email protected]>
1 parent ae02841 commit 725a04a

File tree

7 files changed

+852
-23
lines changed

7 files changed

+852
-23
lines changed

public/previous-contacts-demo.html

Lines changed: 428 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { verifyToken } from '@/lib/auth';
3+
import { Contact } from '@/models/Contact';
4+
5+
export async function GET(request: NextRequest) {
6+
try {
7+
const user = await verifyToken(request);
8+
if (!user) {
9+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
10+
}
11+
12+
const { searchParams } = new URL(request.url);
13+
const callsign = searchParams.get('callsign');
14+
const limit = parseInt(searchParams.get('limit') || '10');
15+
16+
if (!callsign) {
17+
return NextResponse.json({ error: 'Callsign parameter is required' }, { status: 400 });
18+
}
19+
20+
const userId = typeof user.userId === 'string' ? parseInt(user.userId, 10) : user.userId;
21+
22+
const contacts = await Contact.findByCallsignAndUserId(userId, callsign, limit);
23+
24+
// Format the contacts for the response
25+
const formattedContacts = contacts.map(contact => ({
26+
id: contact.id,
27+
datetime: contact.datetime,
28+
band: contact.band,
29+
mode: contact.mode,
30+
frequency: typeof contact.frequency === 'string' ? parseFloat(contact.frequency) : contact.frequency,
31+
rst_sent: contact.rst_sent,
32+
rst_received: contact.rst_received,
33+
name: contact.name,
34+
qth: contact.qth,
35+
notes: contact.notes
36+
}));
37+
38+
return NextResponse.json({
39+
contacts: formattedContacts,
40+
count: formattedContacts.length
41+
});
42+
43+
} catch (error) {
44+
console.error('Error fetching previous contacts:', error);
45+
return NextResponse.json(
46+
{ error: 'Internal server error' },
47+
{ status: 500 }
48+
);
49+
}
50+
}

src/app/new-contact/page.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,30 @@ import { Switch } from '@/components/ui/switch';
1212
import { Textarea } from '@/components/ui/textarea';
1313
import { ArrowLeft, Loader2, Search, Check, AlertCircle, Radio, Clock } from 'lucide-react';
1414
import Navbar from '@/components/Navbar';
15+
import PreviousContacts from '@/components/PreviousContacts';
1516
import ContactLocationMap from '@/components/ContactLocationMap';
1617

18+
1719
interface Station {
1820
id: number;
1921
callsign: string;
2022
station_name: string;
2123
is_default: boolean;
2224
}
2325

26+
interface PreviousContact {
27+
id: number;
28+
datetime: string;
29+
band: string;
30+
mode: string;
31+
frequency: number | string;
32+
rst_sent?: string;
33+
rst_received?: string;
34+
name?: string;
35+
qth?: string;
36+
notes?: string;
37+
}
38+
2439
export default function NewContactPage() {
2540
const [stations, setStations] = useState<Station[]>([]);
2641
const [stationsLoading, setStationsLoading] = useState(true);
@@ -56,13 +71,19 @@ export default function NewContactPage() {
5671
country?: string;
5772
error?: string;
5873
} | null>(null);
74+
75+
const [previousContacts, setPreviousContacts] = useState<PreviousContact[]>([]);
76+
const [previousContactsLoading, setPreviousContactsLoading] = useState(false);
77+
const [previousContactsError, setPreviousContactsError] = useState('');
78+
5979
const [currentUser, setCurrentUser] = useState<{
6080
id: number;
6181
email: string;
6282
name: string;
6383
callsign?: string;
6484
grid_locator?: string;
6585
} | null>(null);
86+
6687
const router = useRouter();
6788

6889
const modes = ['SSB', 'CW', 'FM', 'AM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'OLIVIA', 'CONTESTIA'];
@@ -182,6 +203,49 @@ export default function NewContactPage() {
182203
return () => window.removeEventListener('keydown', handleKeyDown);
183204
}, [formData.callsign, handleCallsignLookup]);
184205

206+
const fetchPreviousContacts = useCallback(async (callsign: string) => {
207+
if (!callsign.trim()) {
208+
setPreviousContacts([]);
209+
return;
210+
}
211+
212+
setPreviousContactsLoading(true);
213+
setPreviousContactsError('');
214+
215+
try {
216+
const response = await fetch(`/api/contacts/previous?callsign=${encodeURIComponent(callsign)}&limit=10`);
217+
218+
if (response.status === 401) {
219+
// User not authenticated, but don't show error for this
220+
setPreviousContacts([]);
221+
return;
222+
}
223+
224+
const data = await response.json();
225+
226+
if (response.ok) {
227+
setPreviousContacts(data.contacts || []);
228+
} else {
229+
setPreviousContactsError(data.error || 'Failed to fetch previous contacts');
230+
setPreviousContacts([]);
231+
}
232+
} catch {
233+
setPreviousContactsError('Network error while fetching previous contacts');
234+
setPreviousContacts([]);
235+
} finally {
236+
setPreviousContactsLoading(false);
237+
}
238+
}, []);
239+
240+
// Fetch previous contacts when callsign changes
241+
useEffect(() => {
242+
const timeoutId = setTimeout(() => {
243+
fetchPreviousContacts(formData.callsign);
244+
}, 500); // Debounce for 500ms to avoid too many API calls
245+
246+
return () => clearTimeout(timeoutId);
247+
}, [formData.callsign, fetchPreviousContacts]);
248+
185249
const fetchStations = async () => {
186250
try {
187251
setStationsLoading(true);
@@ -302,6 +366,7 @@ export default function NewContactPage() {
302366
// Clear lookup result when callsign changes
303367
if (name === 'callsign') {
304368
setLookupResult(null);
369+
// Previous contacts will be fetched via useEffect with debounce
305370
}
306371
};
307372

@@ -586,6 +651,17 @@ export default function NewContactPage() {
586651
)}
587652
</div>
588653

654+
{/* Previous Contacts Section */}
655+
{formData.callsign.trim() && (
656+
<div className="md:col-span-2">
657+
<PreviousContacts
658+
contacts={previousContacts}
659+
loading={previousContactsLoading}
660+
error={previousContactsError}
661+
/>
662+
</div>
663+
)}
664+
589665
<div className="space-y-2">
590666
<Label htmlFor="frequency">Frequency (MHz) *</Label>
591667
<Input
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import React from 'react';
2+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
3+
import { Badge } from '@/components/ui/badge';
4+
import { Clock, Radio, Volume2 } from 'lucide-react';
5+
6+
interface PreviousContact {
7+
id: number;
8+
datetime: string;
9+
band: string;
10+
mode: string;
11+
frequency: number | string;
12+
rst_sent?: string;
13+
rst_received?: string;
14+
name?: string;
15+
qth?: string;
16+
notes?: string;
17+
}
18+
19+
interface PreviousContactsProps {
20+
contacts: PreviousContact[];
21+
loading: boolean;
22+
error?: string;
23+
}
24+
25+
export default function PreviousContacts({ contacts, loading, error }: PreviousContactsProps) {
26+
if (loading) {
27+
return (
28+
<div className="space-y-3 animate-pulse">
29+
<div className="h-4 bg-muted rounded w-40"></div>
30+
<div className="border rounded-md p-4">
31+
<div className="space-y-2">
32+
<div className="h-3 bg-muted rounded w-full"></div>
33+
<div className="h-3 bg-muted rounded w-3/4"></div>
34+
<div className="h-3 bg-muted rounded w-1/2"></div>
35+
</div>
36+
</div>
37+
</div>
38+
);
39+
}
40+
41+
if (error) {
42+
return (
43+
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md p-3">
44+
Error loading previous contacts: {error}
45+
</div>
46+
);
47+
}
48+
49+
if (contacts.length === 0) {
50+
return (
51+
<div className="text-sm text-muted-foreground bg-muted/30 border border-border rounded-md p-4 text-center">
52+
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
53+
<p className="font-medium">No previous contacts found</p>
54+
<p className="text-xs mt-1">This will be your first QSO with this station</p>
55+
</div>
56+
);
57+
}
58+
59+
const formatDateTime = (datetime: string) => {
60+
const date = new Date(datetime);
61+
return {
62+
date: date.toLocaleDateString(),
63+
time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
64+
};
65+
};
66+
67+
const formatFrequency = (freq: number | string) => {
68+
// Convert to number if it's a string
69+
const frequency = typeof freq === 'string' ? parseFloat(freq) : freq;
70+
71+
// Handle invalid numbers
72+
if (isNaN(frequency) || frequency <= 0) {
73+
return 'Unknown';
74+
}
75+
76+
if (frequency >= 1000) {
77+
return `${(frequency / 1000).toFixed(3)} GHz`;
78+
} else if (frequency >= 1) {
79+
return `${frequency.toFixed(3)} MHz`;
80+
} else {
81+
return `${(frequency * 1000).toFixed(0)} kHz`;
82+
}
83+
};
84+
85+
return (
86+
<div className="space-y-3">
87+
<div className="flex items-center justify-between">
88+
<h4 className="text-sm font-medium text-foreground flex items-center">
89+
<Clock className="h-4 w-4 mr-1" />
90+
Previous Contacts ({contacts.length})
91+
</h4>
92+
{contacts.length === 10 && (
93+
<Badge variant="secondary" className="text-xs">
94+
Showing latest 10
95+
</Badge>
96+
)}
97+
</div>
98+
99+
{/* Mobile-friendly card layout */}
100+
<div className="md:hidden space-y-2">
101+
{contacts.map((contact) => {
102+
const { date, time } = formatDateTime(contact.datetime);
103+
return (
104+
<div key={contact.id} className="border border-border rounded-md p-3 bg-card">
105+
<div className="flex items-start justify-between mb-2">
106+
<div className="flex items-center space-x-2">
107+
<Badge variant="outline" className="text-xs">
108+
{contact.band}
109+
</Badge>
110+
<Badge variant="secondary" className="text-xs">
111+
{contact.mode}
112+
</Badge>
113+
</div>
114+
<div className="text-xs text-muted-foreground text-right">
115+
<div>{date}</div>
116+
<div>{time}</div>
117+
</div>
118+
</div>
119+
120+
<div className="text-sm space-y-1">
121+
<div className="flex items-center justify-between">
122+
<span className="text-muted-foreground">Freq:</span>
123+
<span className="font-mono">{formatFrequency(contact.frequency)}</span>
124+
</div>
125+
126+
{(contact.rst_sent || contact.rst_received) && (
127+
<div className="flex items-center justify-between">
128+
<span className="text-muted-foreground flex items-center">
129+
<Volume2 className="h-3 w-3 mr-1" />
130+
RST:
131+
</span>
132+
<span className="font-mono text-xs">
133+
{contact.rst_sent && `Sent: ${contact.rst_sent}`}
134+
{contact.rst_sent && contact.rst_received && ' | '}
135+
{contact.rst_received && `Rcvd: ${contact.rst_received}`}
136+
</span>
137+
</div>
138+
)}
139+
140+
{contact.name && (
141+
<div className="flex items-center justify-between">
142+
<span className="text-muted-foreground">Name:</span>
143+
<span>{contact.name}</span>
144+
</div>
145+
)}
146+
147+
{contact.qth && (
148+
<div className="flex items-center justify-between">
149+
<span className="text-muted-foreground">QTH:</span>
150+
<span className="truncate ml-2">{contact.qth}</span>
151+
</div>
152+
)}
153+
</div>
154+
</div>
155+
);
156+
})}
157+
</div>
158+
159+
{/* Desktop table layout */}
160+
<div className="hidden md:block border border-border rounded-md">
161+
<Table>
162+
<TableHeader>
163+
<TableRow>
164+
<TableHead className="w-24">Date</TableHead>
165+
<TableHead className="w-20">Time</TableHead>
166+
<TableHead className="w-16">Band</TableHead>
167+
<TableHead className="w-16">Mode</TableHead>
168+
<TableHead className="w-24">Frequency</TableHead>
169+
<TableHead className="w-20">RST S/R</TableHead>
170+
<TableHead>Name / QTH</TableHead>
171+
</TableRow>
172+
</TableHeader>
173+
<TableBody>
174+
{contacts.map((contact) => {
175+
const { date, time } = formatDateTime(contact.datetime);
176+
return (
177+
<TableRow key={contact.id}>
178+
<TableCell className="text-xs">{date}</TableCell>
179+
<TableCell className="text-xs font-mono">{time}</TableCell>
180+
<TableCell>
181+
<Badge variant="outline" className="text-xs">
182+
{contact.band}
183+
</Badge>
184+
</TableCell>
185+
<TableCell>
186+
<Badge variant="secondary" className="text-xs">
187+
{contact.mode}
188+
</Badge>
189+
</TableCell>
190+
<TableCell className="text-xs font-mono">
191+
{formatFrequency(contact.frequency)}
192+
</TableCell>
193+
<TableCell className="text-xs font-mono">
194+
{contact.rst_sent && contact.rst_received
195+
? `${contact.rst_sent}/${contact.rst_received}`
196+
: contact.rst_sent || contact.rst_received || '-'
197+
}
198+
</TableCell>
199+
<TableCell className="text-xs">
200+
<div className="space-y-0.5">
201+
{contact.name && (
202+
<div className="font-medium">{contact.name}</div>
203+
)}
204+
{contact.qth && (
205+
<div className="text-muted-foreground truncate">
206+
{contact.qth}
207+
</div>
208+
)}
209+
</div>
210+
</TableCell>
211+
</TableRow>
212+
);
213+
})}
214+
</TableBody>
215+
</Table>
216+
</div>
217+
</div>
218+
);
219+
}

0 commit comments

Comments
 (0)