Compare commits

...

3 Commits

Author SHA1 Message Date
8c4a78ad50 2.2.0 - Final Builds
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m32s
2025-10-18 23:02:31 -06:00
3b16475dc6 [Mobile] 2.2.0 - Calendar View 2025-10-18 16:26:22 -06:00
105d39689d [Mobile] 2.2.0 - Calendar View 2025-10-18 16:26:17 -06:00
14 changed files with 1347 additions and 43 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.ascently"
minSdk = 31
targetSdk = 36
versionCode = 43
versionName = "2.1.1"
versionCode = 45
versionName = "2.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -368,9 +368,22 @@ class SyncService(private val context: Context, private val repository: ClimbRep
repository.setAutoSyncCallback(null)
try {
// Merge and apply deletions first to prevent resurrection
val allDeletions = repository.getDeletedItems() + response.deletedItems
val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" }
Log.d(TAG, "Applying ${uniqueDeletions.size} deletion records before merging data")
applyDeletions(uniqueDeletions)
// Build deleted item lookup set
val deletedItemSet = uniqueDeletions.map { "${it.type}:${it.id}" }.toSet()
// Download images for new/modified problems from server
val imagePathMapping = mutableMapOf<String, String>()
for (problem in response.problems) {
if (deletedItemSet.contains("problem:${problem.id}")) {
continue
}
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
@@ -384,9 +397,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
// Merge gyms - check if exists and compare timestamps
// Merge gyms
val existingGyms = repository.getAllGyms().first()
for (backupGym in response.gyms) {
if (deletedItemSet.contains("gym:${backupGym.id}")) {
continue
}
val existing = existingGyms.find { it.id == backupGym.id }
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
val gym = backupGym.toGym()
@@ -401,6 +417,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// Merge problems
val existingProblems = repository.getAllProblems().first()
for (backupProblem in response.problems) {
if (deletedItemSet.contains("problem:${backupProblem.id}")) {
continue
}
val updatedImagePaths =
backupProblem.imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath
@@ -421,6 +440,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// Merge sessions
val existingSessions = repository.getAllSessions().first()
for (backupSession in response.sessions) {
if (deletedItemSet.contains("session:${backupSession.id}")) {
continue
}
val session = backupSession.toClimbSession()
val existing = existingSessions.find { it.id == backupSession.id }
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
@@ -435,6 +457,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// Merge attempts
val existingAttempts = repository.getAllAttempts().first()
for (backupAttempt in response.attempts) {
if (deletedItemSet.contains("attempt:${backupAttempt.id}")) {
continue
}
val attempt = backupAttempt.toAttempt()
val existing = existingAttempts.find { it.id == backupAttempt.id }
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
@@ -446,15 +471,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
// Apply deletions
applyDeletions(response.deletedItems)
// Apply deletions again for safety
applyDeletions(uniqueDeletions)
// Update deletion records
val allDeletions = repository.getDeletedItems() + response.deletedItems
repository.clearDeletedItems()
allDeletions.distinctBy { "${it.type}:${it.id}" }.forEach {
repository.trackDeletion(it.id, it.type)
}
uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) }
} finally {
// Re-enable auto-sync
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
@@ -542,7 +564,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.put(requestBody)
.build()
withContext(Dispatchers.IO) {

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.window.Dialog
import com.atridad.ascently.data.model.*
import com.atridad.ascently.ui.components.FullscreenImageViewer
import com.atridad.ascently.ui.components.ImageDisplaySection
import com.atridad.ascently.ui.components.ImagePicker
import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.utils.DateFormatUtils
@@ -1489,6 +1490,7 @@ fun EnhancedAddAttemptDialog(
// New problem creation state
var newProblemName by remember { mutableStateOf("") }
var newProblemGrade by remember { mutableStateOf("") }
var newProblemImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) }
var selectedDifficultySystem by remember {
mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE)
@@ -1690,7 +1692,14 @@ fun EnhancedAddAttemptDialog(
color = MaterialTheme.colorScheme.onSurface
)
IconButton(onClick = { showCreateProblem = false }) {
IconButton(
onClick = {
showCreateProblem = false
newProblemName = ""
newProblemGrade = ""
newProblemImagePaths = emptyList()
}
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
@@ -1905,6 +1914,21 @@ fun EnhancedAddAttemptDialog(
}
}
}
// Photos Section
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Photos (Optional)",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
ImagePicker(
imageUris = newProblemImagePaths,
onImagesChanged = { newProblemImagePaths = it },
maxImages = 5
)
}
}
}
}
@@ -2069,7 +2093,9 @@ fun EnhancedAddAttemptDialog(
null
},
climbType = selectedClimbType,
difficulty = difficulty
difficulty = difficulty,
imagePaths =
newProblemImagePaths
)
onProblemCreated(newProblem)
@@ -2087,6 +2113,12 @@ fun EnhancedAddAttemptDialog(
notes = notes.ifBlank { null }
)
onAttemptAdded(attempt)
// Reset form
newProblemName = ""
newProblemGrade = ""
newProblemImagePaths = emptyList()
showCreateProblem = false
}
} else {
// Create attempt for selected problem

View File

@@ -1,16 +1,26 @@
package com.atridad.ascently.ui.screens
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
@@ -23,6 +33,16 @@ import com.atridad.ascently.ui.components.ActiveSessionBanner
import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.utils.DateFormatUtils
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.Locale
enum class ViewMode {
LIST,
CALENDAR
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -33,7 +53,15 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
val activeSession by viewModel.activeSession.collectAsState()
val uiState by viewModel.uiState.collectAsState()
// Filter out active sessions from regular session list
val sharedPreferences =
context.getSharedPreferences("SessionsPreferences", Context.MODE_PRIVATE)
val savedViewMode = sharedPreferences.getString("view_mode", "LIST")
var viewMode by remember {
mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST)
}
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
var selectedDate by remember { mutableStateOf<LocalDate?>(null) }
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
@@ -55,12 +83,30 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
viewMode =
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
selectedDate = null
sharedPreferences.edit().putString("view_mode", viewMode.name).apply()
}
) {
Icon(
imageVector =
if (viewMode == ViewMode.LIST) Icons.Default.CalendarMonth
else Icons.AutoMirrored.Filled.List,
contentDescription =
if (viewMode == ViewMode.LIST) "Calendar View" else "List View",
tint = MaterialTheme.colorScheme.primary
)
}
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
}
Spacer(modifier = Modifier.height(16.dp))
// Active session banner
ActiveSessionBanner(
activeSession = activeSession,
gym = activeSessionGym,
@@ -83,20 +129,40 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
actionText = ""
)
} else {
when (viewMode) {
ViewMode.LIST -> {
LazyColumn {
items(completedSessions) { session ->
SessionCard(
session = session,
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
gymName = gyms.find { it.id == session.gymId }?.name
?: "Unknown Gym",
onClick = { onNavigateToSessionDetail(session.id) }
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
ViewMode.CALENDAR -> {
CalendarView(
sessions = completedSessions,
gyms = gyms,
activeSession = activeSession,
activeSessionGym = activeSessionGym,
selectedMonth = selectedMonth,
onMonthChange = { selectedMonth = it },
selectedDate = selectedDate,
onDateSelected = { selectedDate = it },
onNavigateToSessionDetail = onNavigateToSessionDetail,
onEndSession = {
activeSession?.let { viewModel.endSession(context, it.id) }
}
)
}
}
}
}
// Show UI state messages and errors
uiState.message?.let { message ->
LaunchedEffect(message) {
kotlinx.coroutines.delay(5000)
@@ -245,6 +311,226 @@ fun EmptyStateMessage(
}
}
@Composable
fun CalendarView(
sessions: List<ClimbSession>,
gyms: List<com.atridad.ascently.data.model.Gym>,
activeSession: ClimbSession?,
activeSessionGym: com.atridad.ascently.data.model.Gym?,
selectedMonth: YearMonth,
onMonthChange: (YearMonth) -> Unit,
selectedDate: LocalDate?,
onDateSelected: (LocalDate?) -> Unit,
onNavigateToSessionDetail: (String) -> Unit,
onEndSession: () -> Unit
) {
val sessionsByDate =
remember(sessions) {
sessions.groupBy {
try {
java.time.Instant.parse(it.date)
.atZone(java.time.ZoneId.systemDefault())
.toLocalDate()
} catch (e: Exception) {
LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
Text("", style = MaterialTheme.typography.headlineMedium)
}
Text(
text =
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
Text("", style = MaterialTheme.typography.headlineMedium)
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val today = LocalDate.now()
onMonthChange(YearMonth.from(today))
onDateSelected(today)
},
shape = RoundedCornerShape(50),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp)
) {
Text(
text = "Today",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat").forEach { day ->
Text(
text = day,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
val firstDayOfMonth = selectedMonth.atDay(1)
val daysInMonth = selectedMonth.lengthOfMonth()
val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
val totalCells =
((firstDayOfWeek + daysInMonth) / 7.0).let {
if (it == it.toInt().toDouble()) it.toInt() * 7 else (it.toInt() + 1) * 7
}
LazyVerticalGrid(columns = GridCells.Fixed(7), modifier = Modifier.fillMaxWidth()) {
items(totalCells) { index ->
val dayNumber = index - firstDayOfWeek + 1
if (dayNumber in 1..daysInMonth) {
val date = selectedMonth.atDay(dayNumber)
val sessionsOnDate = sessionsByDate[date] ?: emptyList()
val isSelected = date == selectedDate
val isToday = date == LocalDate.now()
CalendarDay(
day = dayNumber,
hasSession = sessionsOnDate.isNotEmpty(),
isSelected = isSelected,
isToday = isToday,
onClick = {
if (sessionsOnDate.isNotEmpty()) {
onDateSelected(if (isSelected) null else date)
}
}
)
} else {
Spacer(modifier = Modifier.aspectRatio(1f))
}
}
}
if (selectedDate != null) {
val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList()
Spacer(modifier = Modifier.height(16.dp))
Text(
text =
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp)
)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(sessionsOnSelectedDate) { session ->
SessionCard(
session = session,
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
onClick = { onNavigateToSessionDetail(session.id) }
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}
@Composable
fun CalendarDay(
day: Int,
hasSession: Boolean,
isSelected: Boolean,
isToday: Boolean,
onClick: () -> Unit
) {
Box(
modifier =
Modifier.aspectRatio(1f)
.padding(2.dp)
.clip(CircleShape)
.background(
when {
isSelected -> MaterialTheme.colorScheme.primaryContainer
isToday -> MaterialTheme.colorScheme.secondaryContainer
else -> Color.Transparent
}
)
.clickable(enabled = hasSession, onClick = onClick),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = day.toString(),
style = MaterialTheme.typography.bodyMedium,
color =
when {
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
isToday -> MaterialTheme.colorScheme.onSecondaryContainer
!hasSession -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurface
},
fontWeight = if (hasSession || isToday) FontWeight.Bold else FontWeight.Normal
)
if (hasSession) {
Box(
modifier =
Modifier.size(6.dp)
.clip(CircleShape)
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.primary.copy(
alpha = 0.7f
)
)
)
}
}
}
}
private fun formatDate(dateString: String): String {
return DateFormatUtils.formatDateForDisplay(dateString)
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
<path
android:fillColor="#FFC107"
android:pathData="M24,74 L42,34 L60,74 Z" />
<path
android:fillColor="#F44336"
android:pathData="M41,74 L59,24 L84,74 Z" />
</vector>

View File

@@ -4,7 +4,7 @@
<style name="Theme.Ascently.Splash" parent="Theme.Ascently">
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
<item name="android:windowSplashScreenAnimationDuration">200</item>
</style>
</resources>

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 28;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -487,7 +487,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.1.1;
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 28;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -535,7 +535,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.1.1;
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 28;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -613,7 +613,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.1.1;
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 28;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -643,7 +643,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.1.1;
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -158,7 +158,7 @@ class SyncService: ObservableObject {
let modifiedProblems = dataManager.problems.filter { problem in
problem.updatedAt > lastSync
}.map { problem -> BackupProblem in
var backupProblem = BackupProblem(from: problem)
let backupProblem = BackupProblem(from: problem)
if !problem.imagePaths.isEmpty {
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
ImageNamingUtils.generateImageFilename(
@@ -266,9 +266,25 @@ class SyncService: ObservableObject {
{
let formatter = ISO8601DateFormatter()
// Merge and apply deletions first to prevent resurrection
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
print(
"iOS DELTA SYNC: Applying \(uniqueDeletions.count) deletion records before merging data"
)
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
// Build deleted item lookup map
let deletedItemSet = Set(uniqueDeletions.map { $0.type + ":" + $0.id })
// Download images for new/modified problems from server
var imagePathMapping: [String: String] = [:]
for problem in response.problems {
if deletedItemSet.contains("problem:" + problem.id) {
continue
}
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
for (index, imagePath) in imagePaths.enumerated() {
@@ -293,6 +309,10 @@ class SyncService: ObservableObject {
// Merge gyms
for backupGym in response.gyms {
if deletedItemSet.contains("gym:" + backupGym.id) {
continue
}
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id })
{
let existing = dataManager.gyms[index]
@@ -306,6 +326,10 @@ class SyncService: ObservableObject {
// Merge problems
for backupProblem in response.problems {
if deletedItemSet.contains("problem:" + backupProblem.id) {
continue
}
var problemToMerge = backupProblem
if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths {
let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 }
@@ -341,6 +365,10 @@ class SyncService: ObservableObject {
// Merge sessions
for backupSession in response.sessions {
if deletedItemSet.contains("session:" + backupSession.id) {
continue
}
if let index = dataManager.sessions.firstIndex(where: {
$0.id.uuidString == backupSession.id
}) {
@@ -355,6 +383,10 @@ class SyncService: ObservableObject {
// Merge attempts
for backupAttempt in response.attempts {
if deletedItemSet.contains("attempt:" + backupAttempt.id) {
continue
}
if let index = dataManager.attempts.firstIndex(where: {
$0.id.uuidString == backupAttempt.id
}) {
@@ -367,9 +399,7 @@ class SyncService: ObservableObject {
}
}
// Apply deletions
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
// Apply deletions again for safety
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
// Save all changes

View File

@@ -31,7 +31,7 @@ struct OrientationAwareImage: View {
.onAppear {
loadImageWithCorrectOrientation()
}
.onChange(of: imagePath) { _ in
.onChange(of: imagePath) { _, _ in
loadImageWithCorrectOrientation()
}
}

View File

@@ -0,0 +1,338 @@
import SwiftUI
struct CalendarView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
let sessions: [ClimbSession]
@Binding var selectedMonth: Date
@Binding var selectedDate: Date?
let onNavigateToSession: (UUID) -> Void
var calendar: Calendar {
Calendar.current
}
var monthYearString: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter.string(from: selectedMonth)
}
var sessionsByDate: [Date: [ClimbSession]] {
Dictionary(grouping: sessions) { session in
calendar.startOfDay(for: session.date)
}
}
var daysInMonth: [Date?] {
guard let monthInterval = calendar.dateInterval(of: .month, for: selectedMonth),
calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) != nil
else {
return []
}
let days = calendar.generateDates(
inside: monthInterval,
matching: DateComponents(hour: 0, minute: 0, second: 0)
)
let firstDayOfMonth = days.first ?? monthInterval.start
let firstWeekday = calendar.component(.weekday, from: firstDayOfMonth)
let offset = firstWeekday - 1
var paddedDays: [Date?] = Array(repeating: nil, count: offset)
paddedDays.append(contentsOf: days.map { $0 as Date? })
let remainder = paddedDays.count % 7
if remainder != 0 {
paddedDays.append(contentsOf: Array(repeating: nil, count: 7 - remainder))
}
return paddedDays
}
var body: some View {
ScrollView {
VStack(spacing: 0) {
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
ActiveSessionBanner(session: activeSession, gym: gym)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
}
VStack(spacing: 8) {
HStack {
Button(action: { changeMonth(by: -1) }) {
Image(systemName: "chevron.left")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.blue)
}
.frame(width: 44, height: 44)
Spacer()
Text(monthYearString)
.font(.title3)
.fontWeight(.semibold)
Spacer()
Button(action: { changeMonth(by: 1) }) {
Image(systemName: "chevron.right")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.blue)
}
.frame(width: 44, height: 44)
}
Button(action: {
let today = Date()
selectedMonth = today
selectedDate = today
}) {
Text("Today")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(Color.blue)
.clipShape(Capsule())
}
}
.padding(.vertical, 16)
.padding(.horizontal)
HStack(spacing: 0) {
ForEach(["S", "M", "T", "W", "T", "F", "S"], id: \.self) { day in
Text(day)
.font(.caption2)
.fontWeight(.semibold)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal)
.padding(.bottom, 8)
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7),
spacing: 4
) {
ForEach(daysInMonth.indices, id: \.self) { index in
if let date = daysInMonth[index] {
CalendarDayCell(
date: date,
sessions: sessionsByDate[calendar.startOfDay(for: date)] ?? [],
isSelected: selectedDate.map {
calendar.isDate($0, inSameDayAs: date)
}
?? false,
isToday: calendar.isDateInToday(date),
isInCurrentMonth: calendar.isDate(
date, equalTo: selectedMonth, toGranularity: .month)
) {
if !sessionsByDate[calendar.startOfDay(for: date), default: []]
.isEmpty
{
if selectedDate.map({ calendar.isDate($0, inSameDayAs: date) })
?? false
{
selectedDate = nil
} else {
selectedDate = date
}
}
}
} else {
Color.clear
.aspectRatio(1, contentMode: .fit)
}
}
}
.padding(.horizontal)
if let selected = selectedDate,
let sessionsOnDate = sessionsByDate[calendar.startOfDay(for: selected)],
!sessionsOnDate.isEmpty
{
Divider()
.padding(.vertical, 16)
.padding(.horizontal)
VStack(alignment: .leading, spacing: 12) {
Text("Sessions on \(formatSelectedDate(selected))")
.font(.headline)
.fontWeight(.semibold)
.padding(.horizontal)
VStack(spacing: 12) {
ForEach(sessionsOnDate) { session in
SessionCard(
session: session,
onTap: {
onNavigateToSession(session.id)
}
)
.padding(.horizontal)
}
}
}
.padding(.bottom, 16)
}
}
}
}
func changeMonth(by value: Int) {
if let newMonth = calendar.date(byAdding: .month, value: value, to: selectedMonth) {
selectedMonth = newMonth
selectedDate = nil
}
}
func formatSelectedDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM d, yyyy"
return formatter.string(from: date)
}
}
struct CalendarDayCell: View {
let date: Date
let sessions: [ClimbSession]
let isSelected: Bool
let isToday: Bool
let isInCurrentMonth: Bool
let onTap: () -> Void
var dayNumber: String {
let formatter = DateFormatter()
formatter.dateFormat = "d"
return formatter.string(from: date)
}
var body: some View {
Button(action: onTap) {
VStack(spacing: 6) {
Text(dayNumber)
.font(.system(size: 17))
.fontWeight(sessions.isEmpty ? .regular : .medium)
.foregroundColor(
isSelected
? .white
: isToday
? .blue
: !isInCurrentMonth
? .secondary.opacity(0.3)
: sessions.isEmpty ? .secondary : .primary
)
if !sessions.isEmpty {
Circle()
.fill(isSelected ? .white : .blue)
.frame(width: 4, height: 4)
} else {
Spacer()
.frame(height: 4)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6)
.fill(
isSelected ? Color.blue : isToday ? Color.blue.opacity(0.1) : Color.clear
)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
isToday && !isSelected ? Color.blue.opacity(0.3) : Color.clear, lineWidth: 1
)
)
}
.buttonStyle(PlainButtonStyle())
.disabled(sessions.isEmpty)
}
}
struct SessionCard: View {
@EnvironmentObject var dataManager: ClimbingDataManager
let session: ClimbSession
let onTap: () -> Void
var gym: Gym? {
dataManager.gym(withId: session.gymId)
}
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text(gym?.name ?? "Unknown Gym")
.font(.body)
.fontWeight(.semibold)
.foregroundColor(.primary)
if let duration = session.duration {
Text("Duration: \(duration) minutes")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let notes = session.notes, !notes.isEmpty {
Text(notes)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.body)
.fontWeight(.semibold)
.foregroundColor(Color(.tertiaryLabel))
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color(.secondarySystemGroupedBackground))
)
.onTapGesture {
onTap()
}
}
}
extension Calendar {
func generateDates(
inside interval: DateInterval,
matching components: DateComponents
) -> [Date] {
var dates: [Date] = []
dates.append(interval.start)
enumerateDates(
startingAfter: interval.start,
matching: components,
matchingPolicy: .nextTime
) { date, _, stop in
if let date = date {
if date < interval.end {
dates.append(date)
} else {
stop = true
}
}
}
return dates
}
}

View File

@@ -1,9 +1,24 @@
import Combine
import SwiftUI
enum SessionViewMode: String {
case list
case calendar
}
struct SessionsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddSession = false
@AppStorage("sessionViewMode") private var viewMode: SessionViewMode = .list
@State private var selectedMonth = Date()
@State private var selectedDate: Date? = nil
@State private var selectedSessionId: UUID? = nil
private var completedSessions: [ClimbSession] {
dataManager.sessions
.filter { $0.status == .completed }
.sorted { $0.date > $1.date }
}
var body: some View {
NavigationStack {
@@ -11,7 +26,18 @@ struct SessionsView: View {
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
EmptySessionsView()
} else {
if viewMode == .list {
SessionsList()
} else {
CalendarView(
sessions: completedSessions,
selectedMonth: $selectedMonth,
selectedDate: $selectedDate,
onNavigateToSession: { sessionId in
selectedSessionId = sessionId
}
)
}
}
}
.navigationTitle("Sessions")
@@ -36,6 +62,20 @@ struct SessionsView: View {
)
}
// View mode toggle
if !dataManager.sessions.isEmpty || dataManager.activeSession != nil {
Button(action: {
withAnimation(.easeInOut(duration: 0.2)) {
viewMode = viewMode == .list ? .calendar : .list
selectedDate = nil
}
}) {
Image(systemName: viewMode == .list ? "calendar" : "list.bullet")
.font(.body)
.fontWeight(.semibold)
}
}
if dataManager.gyms.isEmpty {
EmptyView()
} else if dataManager.activeSession == nil {
@@ -52,6 +92,14 @@ struct SessionsView: View {
.sheet(isPresented: $showingAddSession) {
AddEditSessionView()
}
.navigationDestination(isPresented: .constant(selectedSessionId != nil)) {
if let sessionId = selectedSessionId {
SessionDetailView(sessionId: sessionId)
.onDisappear {
selectedSessionId = nil
}
}
}
}
}
}

View File

@@ -13,7 +13,7 @@ import (
"time"
)
const VERSION = "2.1.0"
const VERSION = "2.2.0"
func min(a, b int) int {
if a < b {
@@ -283,8 +283,16 @@ func (s *SyncServer) mergeDeletedItems(existing []DeletedItem, updates []Deleted
}
}
// Clean up tombstones older than 30 days to prevent unbounded growth
cutoffTime := time.Now().UTC().Add(-30 * 24 * time.Hour)
result := make([]DeletedItem, 0, len(deletedMap))
for _, item := range deletedMap {
deletedTime, err := time.Parse(time.RFC3339, item.DeletedAt)
if err == nil && deletedTime.Before(cutoffTime) {
log.Printf("Cleaning up old deletion record: type=%s, id=%s, deletedAt=%s",
item.Type, item.ID, item.DeletedAt)
continue
}
result = append(result, item)
}
return result
@@ -533,15 +541,16 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
return
}
// Merge and apply deletions first to prevent resurrection
serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems)
s.applyDeletions(serverBackup, serverBackup.DeletedItems)
log.Printf("Applied deletions: total=%d deletion records", len(serverBackup.DeletedItems))
// Merge client changes into server data
serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms)
serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems)
serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions)
serverBackup.Attempts = s.mergeAttempts(serverBackup.Attempts, deltaRequest.Attempts)
serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems)
// Apply deletions to remove deleted items
s.applyDeletions(serverBackup, serverBackup.DeletedItems)
// Save merged data
if err := s.saveData(serverBackup); err != nil {
@@ -553,8 +562,15 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
// Parse client's last sync time
clientLastSync, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime)
if err != nil {
// If parsing fails, send everything
clientLastSync = time.Time{}
log.Printf("Warning: Could not parse lastSyncTime '%s', sending all data", deltaRequest.LastSyncTime)
}
// Build deleted item lookup map
deletedItemMap := make(map[string]bool)
for _, item := range serverBackup.DeletedItems {
key := item.Type + ":" + item.ID
deletedItemMap[key] = true
}
// Prepare response with items modified since client's last sync
@@ -569,6 +585,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
// Filter gyms modified after client's last sync
for _, gym := range serverBackup.Gyms {
if deletedItemMap["gym:"+gym.ID] {
continue
}
gymTime, err := time.Parse(time.RFC3339, gym.UpdatedAt)
if err == nil && gymTime.After(clientLastSync) {
response.Gyms = append(response.Gyms, gym)
@@ -577,6 +596,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
// Filter problems modified after client's last sync
for _, problem := range serverBackup.Problems {
if deletedItemMap["problem:"+problem.ID] {
continue
}
problemTime, err := time.Parse(time.RFC3339, problem.UpdatedAt)
if err == nil && problemTime.After(clientLastSync) {
response.Problems = append(response.Problems, problem)
@@ -585,6 +607,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
// Filter sessions modified after client's last sync
for _, session := range serverBackup.Sessions {
if deletedItemMap["session:"+session.ID] {
continue
}
sessionTime, err := time.Parse(time.RFC3339, session.UpdatedAt)
if err == nil && sessionTime.After(clientLastSync) {
response.Sessions = append(response.Sessions, session)
@@ -593,6 +618,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
// Filter attempts created after client's last sync
for _, attempt := range serverBackup.Attempts {
if deletedItemMap["attempt:"+attempt.ID] {
continue
}
attemptTime, err := time.Parse(time.RFC3339, attempt.CreatedAt)
if err == nil && attemptTime.After(clientLastSync) {
response.Attempts = append(response.Attempts, attempt)

501
sync/sync_test.go Normal file
View File

@@ -0,0 +1,501 @@
package main
import (
"path/filepath"
"testing"
"time"
)
// TestDeltaSyncDeletedItemResurrection verifies deleted items don't resurrect
func TestDeltaSyncDeletedItemResurrection(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
authToken: "test-token",
}
// Initial state: Server has one gym, one problem, one session with 8 attempts
now := time.Now().UTC()
gymID := "gym-1"
problemID := "problem-1"
sessionID := "session-1"
initialBackup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{
{
ID: gymID,
Name: "Test Gym",
SupportedClimbTypes: []string{"BOULDER"},
DifficultySystems: []string{"V"},
CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
},
},
Problems: []BackupProblem{
{
ID: problemID,
GymID: gymID,
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
IsActive: true,
CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
},
},
Sessions: []BackupClimbSession{
{
ID: sessionID,
GymID: gymID,
Date: now.Format("2006-01-02"),
Status: "completed",
CreatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339),
UpdatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339),
},
},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Add 8 attempts
for i := 0; i < 8; i++ {
attempt := BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: sessionID,
ProblemID: problemID,
Result: "COMPLETED",
Timestamp: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339),
CreatedAt: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339),
}
initialBackup.Attempts = append(initialBackup.Attempts, attempt)
}
if err := server.saveData(initialBackup); err != nil {
t.Fatalf("Failed to save initial data: %v", err)
}
// Client 1 syncs - gets all data
client1LastSync := now.Add(-2 * time.Hour).Format(time.RFC3339)
deltaRequest1 := DeltaSyncRequest{
LastSyncTime: client1LastSync,
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Simulate delta sync for client 1
serverBackup, _ := server.loadData()
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest1.DeletedItems)
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
if len(serverBackup.Sessions) != 1 {
t.Errorf("Expected 1 session after client1 sync, got %d", len(serverBackup.Sessions))
}
if len(serverBackup.Attempts) != 8 {
t.Errorf("Expected 8 attempts after client1 sync, got %d", len(serverBackup.Attempts))
}
// Client 1 deletes the session locally
deleteTime := now.Format(time.RFC3339)
deletions := []DeletedItem{
{ID: sessionID, Type: "session", DeletedAt: deleteTime},
}
// Also track attempt deletions
for _, attempt := range initialBackup.Attempts {
deletions = append(deletions, DeletedItem{
ID: attempt.ID,
Type: "attempt",
DeletedAt: deleteTime,
})
}
// Client 1 syncs deletion
deltaRequest2 := DeltaSyncRequest{
LastSyncTime: now.Add(-5 * time.Minute).Format(time.RFC3339),
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: deletions,
}
// Server processes deletion
serverBackup, _ = server.loadData()
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest2.DeletedItems)
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
server.saveData(serverBackup)
// Verify deletions were applied on server
serverBackup, _ = server.loadData()
if len(serverBackup.Sessions) != 0 {
t.Errorf("Expected 0 sessions after deletion, got %d", len(serverBackup.Sessions))
}
if len(serverBackup.Attempts) != 0 {
t.Errorf("Expected 0 attempts after deletion, got %d", len(serverBackup.Attempts))
}
if len(serverBackup.DeletedItems) != 9 {
t.Errorf("Expected 9 deletion records, got %d", len(serverBackup.DeletedItems))
}
// Client does local reset and pulls from server
deltaRequest3 := DeltaSyncRequest{
LastSyncTime: time.Time{}.Format(time.RFC3339),
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
serverBackup, _ = server.loadData()
clientLastSync, _ := time.Parse(time.RFC3339, deltaRequest3.LastSyncTime)
// Build response
response := DeltaSyncResponse{
ServerTime: time.Now().UTC().Format(time.RFC3339),
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Build deleted item map
deletedItemMap := make(map[string]bool)
for _, item := range serverBackup.DeletedItems {
key := item.Type + ":" + item.ID
deletedItemMap[key] = true
}
// Filter sessions (excluding deleted)
for _, session := range serverBackup.Sessions {
if deletedItemMap["session:"+session.ID] {
continue
}
sessionTime, _ := time.Parse(time.RFC3339, session.UpdatedAt)
if sessionTime.After(clientLastSync) {
response.Sessions = append(response.Sessions, session)
}
}
// Filter attempts (excluding deleted)
for _, attempt := range serverBackup.Attempts {
if deletedItemMap["attempt:"+attempt.ID] {
continue
}
attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt)
if attemptTime.After(clientLastSync) {
response.Attempts = append(response.Attempts, attempt)
}
}
// Send deletion records
for _, deletion := range serverBackup.DeletedItems {
deletionTime, _ := time.Parse(time.RFC3339, deletion.DeletedAt)
if deletionTime.After(clientLastSync) {
response.DeletedItems = append(response.DeletedItems, deletion)
}
}
if len(response.Sessions) != 0 {
t.Errorf("Deleted session was resurrected! Got %d sessions in response", len(response.Sessions))
}
if len(response.Attempts) != 0 {
t.Errorf("Deleted attempts were resurrected! Got %d attempts in response", len(response.Attempts))
}
if len(response.DeletedItems) < 9 {
t.Errorf("Expected at least 9 deletion records in response, got %d", len(response.DeletedItems))
}
}
// TestDeltaSyncAttemptCount verifies all attempts are preserved
func TestDeltaSyncAttemptCount(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
authToken: "test-token",
}
now := time.Now().UTC()
gymID := "gym-1"
problemID := "problem-1"
sessionID := "session-1"
// Create session with 8 attempts
initialBackup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: gymID, Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: problemID, GymID: gymID, ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: sessionID, GymID: gymID, Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Add 8 attempts at different times
baseTime := now.Add(-30 * time.Minute)
for i := 0; i < 8; i++ {
attempt := BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: sessionID,
ProblemID: problemID,
Result: "COMPLETED",
Timestamp: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
CreatedAt: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
}
initialBackup.Attempts = append(initialBackup.Attempts, attempt)
}
if err := server.saveData(initialBackup); err != nil {
t.Fatalf("Failed to save initial data: %v", err)
}
// Client syncs with lastSyncTime BEFORE all attempts were created
clientLastSync := baseTime.Add(-1 * time.Hour)
serverBackup, _ := server.loadData()
// Count attempts that should be returned
attemptCount := 0
for _, attempt := range serverBackup.Attempts {
attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt)
if attemptTime.After(clientLastSync) {
attemptCount++
}
}
if attemptCount != 8 {
t.Errorf("Expected all 8 attempts to be returned, got %d", attemptCount)
}
}
// TestTombstoneCleanup verifies old deletion records are cleaned up
func TestTombstoneCleanup(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
oldDeletion := DeletedItem{
ID: "old-item",
Type: "session",
DeletedAt: now.Add(-31 * 24 * time.Hour).Format(time.RFC3339), // 31 days old
}
recentDeletion := DeletedItem{
ID: "recent-item",
Type: "session",
DeletedAt: now.Add(-1 * 24 * time.Hour).Format(time.RFC3339), // 1 day old
}
existing := []DeletedItem{oldDeletion}
updates := []DeletedItem{recentDeletion}
merged := server.mergeDeletedItems(existing, updates)
// Old deletion should be cleaned up, only recent one remains
if len(merged) != 1 {
t.Errorf("Expected 1 deletion record after cleanup, got %d", len(merged))
}
if len(merged) > 0 && merged[0].ID != "recent-item" {
t.Errorf("Expected recent deletion to remain, got %s", merged[0].ID)
}
}
// TestMergeDeletedItemsDeduplication verifies duplicate deletions are handled
func TestMergeDeletedItemsDeduplication(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
deletion1 := DeletedItem{
ID: "item-1",
Type: "session",
DeletedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
}
deletion2 := DeletedItem{
ID: "item-1",
Type: "session",
DeletedAt: now.Format(time.RFC3339), // Newer timestamp
}
existing := []DeletedItem{deletion1}
updates := []DeletedItem{deletion2}
merged := server.mergeDeletedItems(existing, updates)
if len(merged) != 1 {
t.Errorf("Expected 1 deletion record, got %d", len(merged))
}
if len(merged) > 0 && merged[0].DeletedAt != deletion2.DeletedAt {
t.Errorf("Expected newer deletion timestamp to be kept")
}
}
// TestApplyDeletions verifies deletions are applied correctly
func TestApplyDeletions(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
backup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{{ID: "attempt-1", SessionID: "session-1", ProblemID: "problem-1", Result: "COMPLETED", Timestamp: now.Format(time.RFC3339), CreatedAt: now.Format(time.RFC3339)}},
DeletedItems: []DeletedItem{},
}
deletions := []DeletedItem{
{ID: "session-1", Type: "session", DeletedAt: now.Format(time.RFC3339)},
{ID: "attempt-1", Type: "attempt", DeletedAt: now.Format(time.RFC3339)},
}
server.applyDeletions(backup, deletions)
if len(backup.Sessions) != 0 {
t.Errorf("Expected 0 sessions after deletion, got %d", len(backup.Sessions))
}
if len(backup.Attempts) != 0 {
t.Errorf("Expected 0 attempts after deletion, got %d", len(backup.Attempts))
}
if len(backup.Gyms) != 1 {
t.Errorf("Expected gym to remain, got %d gyms", len(backup.Gyms))
}
if len(backup.Problems) != 1 {
t.Errorf("Expected problem to remain, got %d problems", len(backup.Problems))
}
}
// TestCascadingDeletions verifies related items are handled properly
func TestCascadingDeletions(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
sessionID := "session-1"
backup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: sessionID, GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Add multiple attempts for the session
for i := 0; i < 5; i++ {
backup.Attempts = append(backup.Attempts, BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: sessionID,
ProblemID: "problem-1",
Result: "COMPLETED",
Timestamp: now.Format(time.RFC3339),
CreatedAt: now.Format(time.RFC3339),
})
}
// Delete session - attempts should also be tracked as deleted
deletions := []DeletedItem{
{ID: sessionID, Type: "session", DeletedAt: now.Format(time.RFC3339)},
}
for _, attempt := range backup.Attempts {
deletions = append(deletions, DeletedItem{
ID: attempt.ID,
Type: "attempt",
DeletedAt: now.Format(time.RFC3339),
})
}
server.applyDeletions(backup, deletions)
if len(backup.Sessions) != 0 {
t.Errorf("Expected session to be deleted, got %d sessions", len(backup.Sessions))
}
if len(backup.Attempts) != 0 {
t.Errorf("Expected all attempts to be deleted, got %d attempts", len(backup.Attempts))
}
}
// TestFullSyncAfterReset verifies the reported user scenario
func TestFullSyncAfterReset(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
authToken: "test-token",
}
now := time.Now().UTC()
// Initial sync with data
initialData := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
for i := 0; i < 8; i++ {
initialData.Attempts = append(initialData.Attempts, BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: "session-1",
ProblemID: "problem-1",
Result: "COMPLETED",
Timestamp: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
CreatedAt: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
})
}
server.saveData(initialData)
// Client deletes everything and syncs
deletions := []DeletedItem{
{ID: "gym-1", Type: "gym", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
{ID: "problem-1", Type: "problem", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
{ID: "session-1", Type: "session", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
}
for i := 0; i < 8; i++ {
deletions = append(deletions, DeletedItem{
ID: "attempt-" + string(rune('1'+i)),
Type: "attempt",
DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339),
})
}
serverBackup, _ := server.loadData()
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deletions)
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
server.saveData(serverBackup)
// Client does local reset and pulls from server
serverBackup, _ = server.loadData()
if len(serverBackup.Gyms) != 0 {
t.Errorf("Expected 0 gyms, got %d", len(serverBackup.Gyms))
}
if len(serverBackup.Problems) != 0 {
t.Errorf("Expected 0 problems, got %d", len(serverBackup.Problems))
}
if len(serverBackup.Sessions) != 0 {
t.Errorf("Expected 0 sessions, got %d", len(serverBackup.Sessions))
}
if len(serverBackup.Attempts) != 0 {
t.Errorf("Expected 0 attempts, got %d", len(serverBackup.Attempts))
}
if len(serverBackup.DeletedItems) == 0 {
t.Errorf("Expected deletion records, got 0")
}
}