diff --git a/frontend/src/components/Calls.jsx b/frontend/src/components/Calls.jsx
new file mode 100644
index 0000000..54c2c39
--- /dev/null
+++ b/frontend/src/components/Calls.jsx
@@ -0,0 +1,279 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import axios from 'axios'
+
+const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
+const PAGE_SIZE = 50
+
+function Calls({ startDate, endDate }) {
+ const [calls, setCalls] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [loadingMore, setLoadingMore] = useState(false)
+ const [hasMore, setHasMore] = useState(true)
+ const [offset, setOffset] = useState(0)
+ const observerTarget = useRef(null)
+ const scrollContainerRef = useRef(null)
+
+ // Reset when date range changes
+ useEffect(() => {
+ setCalls([])
+ setOffset(0)
+ setHasMore(true)
+ fetchCalls(0, false)
+ }, [startDate, endDate])
+
+ const fetchCalls = async (currentOffset, append = false) => {
+ if (append) {
+ setLoadingMore(true)
+ } else {
+ setLoading(true)
+ }
+
+ try {
+ const params = {
+ limit: PAGE_SIZE,
+ offset: currentOffset
+ }
+ if (startDate) params.start = startDate.toISOString()
+ if (endDate) params.end = endDate.toISOString()
+
+ const response = await axios.get(`${API_BASE}/calls`, { params })
+ const newCalls = response.data || []
+
+ // If we got fewer items than the page size, we've reached the end
+ if (newCalls.length < PAGE_SIZE) {
+ setHasMore(false)
+ }
+
+ if (append) {
+ setCalls(prev => [...prev, ...newCalls])
+ } else {
+ setCalls(newCalls)
+ }
+ } catch (error) {
+ console.error('Error fetching calls:', error)
+ } finally {
+ setLoading(false)
+ setLoadingMore(false)
+ }
+ }
+
+ const loadMore = useCallback(() => {
+ if (!loadingMore && hasMore) {
+ const newOffset = offset + PAGE_SIZE
+ setOffset(newOffset)
+ fetchCalls(newOffset, true)
+ }
+ }, [offset, loadingMore, hasMore])
+
+ // Set up intersection observer for infinite scroll
+ useEffect(() => {
+ if (!scrollContainerRef.current || !observerTarget.current) {
+ return
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && hasMore && !loadingMore) {
+ loadMore()
+ }
+ },
+ {
+ root: scrollContainerRef.current,
+ rootMargin: '100px',
+ threshold: 0.1
+ }
+ )
+
+ observer.observe(observerTarget.current)
+
+ return () => {
+ observer.disconnect()
+ }
+ }, [loadMore, hasMore, loadingMore, calls])
+
+ const formatCallType = (type) => {
+ switch (type) {
+ case 1: return { label: 'Incoming call', icon: '📞', color: 'success' }
+ case 2: return { label: 'Outgoing call', icon: '📱', color: 'primary' }
+ case 3: return { label: 'Missed call', icon: '📵', color: 'danger' }
+ case 4: return { label: 'Voicemail', icon: '🎙️', color: 'info' }
+ case 5: return { label: 'Rejected call', icon: '🚫', color: 'warning' }
+ case 6: return { label: 'Refused call', icon: '❌', color: 'danger' }
+ default: return { label: 'Call', icon: '📞', color: 'secondary' }
+ }
+ }
+
+ const formatDuration = (seconds) => {
+ if (seconds < 60) return `${seconds}s`
+ const minutes = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ return `${minutes}m ${secs}s`
+ }
+
+ const formatPhoneNumber = (phoneNumber) => {
+ if (!phoneNumber) return ''
+
+ // Remove any non-numeric characters except leading +
+ let cleaned = phoneNumber.replace(/[^\d+]/g, '')
+
+ // Handle +1 prefix (US numbers)
+ if (cleaned.startsWith('+1') && cleaned.length === 12) {
+ // Format as +1 (XXX) XXX-XXXX
+ return `+1 (${cleaned.slice(2, 5)}) ${cleaned.slice(5, 8)}-${cleaned.slice(8)}`
+ }
+
+ // Handle numbers with + country code
+ if (cleaned.startsWith('+')) {
+ return cleaned // Return international numbers as-is
+ }
+
+ // Handle 11-digit numbers starting with 1 (US numbers without +)
+ if (cleaned.length === 11 && cleaned.startsWith('1')) {
+ return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
+ }
+
+ // Handle 10-digit US numbers
+ if (cleaned.length === 10) {
+ return `+1 (${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
+ }
+
+ // Return as-is if format doesn't match
+ return phoneNumber
+ }
+
+ const formatDate = (dateStr) => {
+ const date = new Date(dateStr)
+ const now = new Date()
+ const diffMs = now - date
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
+
+ const timeStr = date.toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true
+ })
+
+ if (diffDays === 0) return `Today at ${timeStr}`
+ if (diffDays === 1) return `Yesterday at ${timeStr}`
+ if (diffDays < 7) return date.toLocaleDateString('en-US', {
+ weekday: 'long',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true
+ })
+
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true
+ })
+ }
+
+ if (loading) {
+ return (
+
+
+
+ Loading...
+
+
Loading calls...
+
+
+ )
+ }
+
+ if (calls.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+ Call History
+ {calls.length} calls
+
+
+
+
+
+ {calls.map((call) => {
+ const callType = formatCallType(call.type)
+ const formattedNumber = formatPhoneNumber(call.number)
+ const displayName = call.contact_name || formattedNumber
+
+ return (
+
+
+
+
+
{callType.icon}
+
+
+ {displayName}
+
+ {formattedNumber}
+
+
+
+ {callType.label}
+
+ {formatDate(call.date)}
+
+
+ {call.duration > 0 && (
+
+
+
+ Duration: {formatDuration(call.duration)}
+
+
+ )}
+
+
+ )
+ })}
+
+ {/* Infinite scroll trigger */}
+
+
+ {/* Loading more indicator */}
+ {loadingMore && (
+
+
+ Loading more...
+
+
Loading more calls...
+
+ )}
+
+ {/* End of results indicator */}
+ {!hasMore && calls.length > 0 && (
+
+ No more calls to load
+
+ )}
+
+
+
+ )
+}
+
+export default Calls
diff --git a/internal/database.go b/internal/database.go
index 123f6da..0dea565 100644
--- a/internal/database.go
+++ b/internal/database.go
@@ -1,6 +1,5 @@
package internal
-
import (
"database/sql"
"fmt"
@@ -613,6 +612,49 @@ func GetCallLogs(userDB *sql.DB, number string, startDate, endDate *time.Time) (
return calls, nil
}
+func GetAllCalls(userDB *sql.DB, startDate, endDate *time.Time, limit, offset int) ([]CallLog, error) {
+ query := `
+ SELECT id, address, duration, date, type,
+ COALESCE(presentation, 0), COALESCE(subscription_id, ''), COALESCE(contact_name, '')
+ FROM messages
+ WHERE record_type = 3 -- 3 = call
+ `
+
+ args := []interface{}{}
+ if startDate != nil {
+ query += " AND date >= ?"
+ args = append(args, startDate.Unix())
+ }
+ if endDate != nil {
+ query += " AND date <= ?"
+ args = append(args, endDate.Unix())
+ }
+
+ query += " ORDER BY date ASC LIMIT ? OFFSET ?"
+ args = append(args, limit, offset)
+
+ rows, err := userDB.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ calls := []CallLog{}
+ for rows.Next() {
+ var c CallLog
+ var dateUnix int64
+ err := rows.Scan(&c.ID, &c.Number, &c.Duration, &dateUnix, &c.Type,
+ &c.Presentation, &c.SubscriptionID, &c.ContactName)
+ if err != nil {
+ return nil, err
+ }
+ c.Date = time.Unix(dateUnix, 0)
+ calls = append(calls, c)
+ }
+
+ return calls, nil
+}
+
func GetActivity(userDB *sql.DB, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) {
return GetActivityByAddress(userDB, "", startDate, endDate, limit, offset)
}
diff --git a/internal/handlers.go b/internal/handlers.go
index b125851..f211063 100644
--- a/internal/handlers.go
+++ b/internal/handlers.go
@@ -1,6 +1,5 @@
package internal
-
import (
"database/sql"
"fmt"
@@ -249,6 +248,58 @@ func HandleActivity(c echo.Context) error {
return c.JSON(http.StatusOK, activities)
}
+func HandleCalls(c echo.Context) error {
+ userDB, err := getUserDB(c)
+ if err != nil {
+ slog.Error("Error getting user database", "error", err)
+ return c.JSON(http.StatusInternalServerError, map[string]string{
+ "error": "Failed to get user database",
+ })
+ }
+
+ var startDate, endDate *time.Time
+
+ if startStr := c.QueryParam("start"); startStr != "" {
+ t, err := time.Parse(time.RFC3339, startStr)
+ if err == nil {
+ startDate = &t
+ }
+ }
+
+ if endStr := c.QueryParam("end"); endStr != "" {
+ t, err := time.Parse(time.RFC3339, endStr)
+ if err == nil {
+ endDate = &t
+ }
+ }
+
+ // Parse pagination parameters
+ limit := 50 // default limit
+ offset := 0 // default offset
+
+ if limitStr := c.QueryParam("limit"); limitStr != "" {
+ if val, err := strconv.Atoi(limitStr); err == nil {
+ limit = val
+ }
+ }
+
+ if offsetStr := c.QueryParam("offset"); offsetStr != "" {
+ if val, err := strconv.Atoi(offsetStr); err == nil {
+ offset = val
+ }
+ }
+
+ calls, err := GetAllCalls(userDB, startDate, endDate, limit, offset)
+ if err != nil {
+ slog.Error("Error getting calls", "error", err)
+ return c.JSON(http.StatusInternalServerError, map[string]string{
+ "error": "Failed to get calls",
+ })
+ }
+
+ return c.JSON(http.StatusOK, calls)
+}
+
func HandleDateRange(c echo.Context) error {
userDB, err := getUserDB(c)
if err != nil {
diff --git a/main.go b/main.go
index 304c3eb..0247632 100644
--- a/main.go
+++ b/main.go
@@ -7,9 +7,9 @@ import (
"os"
"time"
- "github.com/lowcarbdev/sbv/internal"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
+ "github.com/lowcarbdev/sbv/internal"
)
var logger *slog.Logger
@@ -69,6 +69,7 @@ func main() {
protected.GET("/conversations", internal.HandleConversations)
protected.GET("/messages", internal.HandleMessages)
protected.GET("/activity", internal.HandleActivity)
+ protected.GET("/calls", internal.HandleCalls)
protected.GET("/daterange", internal.HandleDateRange)
protected.GET("/progress", internal.HandleProgress)
protected.GET("/media", internal.HandleMedia)