feat: Add sync functionality to include deleted items in updates

This commit is contained in:
2025-10-24 20:58:54 +02:00
parent 9aa7ed8878
commit 57e1001e1a
6 changed files with 90 additions and 271 deletions
@@ -27,6 +27,7 @@ interface FieldDao {
@Query("DELETE FROM fields WHERE id = :fieldId")
suspend fun deleteField(fieldId: String)
// Get all fields updated since timestamp, including deleted ones for sync
@Query("SELECT * FROM fields WHERE updatedAt >= :since")
suspend fun getFieldsUpdatedSince(since: Long): List<Field>
}
@@ -32,6 +32,7 @@ interface ItemDao {
@Query("DELETE FROM items WHERE id = :itemId")
suspend fun deleteItem(itemId: String)
// Get all items updated since timestamp, including deleted ones for sync
@Query("SELECT * FROM items WHERE updatedAt >= :since")
suspend fun getItemsUpdatedSince(since: Long): List<Item>
}
@@ -32,6 +32,7 @@ interface ListDao {
@Query("DELETE FROM lists WHERE id = :listId")
suspend fun deleteList(listId: String)
// Get all lists updated since timestamp, including deleted ones for sync
@Query("SELECT * FROM lists WHERE updatedAt >= :since")
suspend fun getListsUpdatedSince(since: Long): List<CollabList>
}
@@ -1300,12 +1300,35 @@ fun ManageColumnsDialog(
var showAddField by remember { mutableStateOf(false) }
var fieldToEdit by remember { mutableStateOf<Field?>(null) }
var fieldToDelete by remember { mutableStateOf<Field?>(null) }
val reorderedFields = remember { mutableStateListOf<Field>().apply { addAll(fields) } }
// Update reordered list when fields change
// Use derivedStateOf to keep reorderedFields in sync with fields
val reorderedFields = remember(fields) {
mutableStateListOf<Field>().apply {
clear()
addAll(fields)
}
}
// Watch for changes in fields and update reorderedFields
LaunchedEffect(fields) {
reorderedFields.clear()
reorderedFields.addAll(fields)
val currentIds = reorderedFields.map { it.id }.toSet()
val newIds = fields.map { it.id }.toSet()
// Add new fields
fields.filter { it.id !in currentIds }.forEach { newField ->
reorderedFields.add(newField)
}
// Remove deleted fields
reorderedFields.removeAll { it.id !in newIds }
// Update existing fields (in case of edits)
fields.forEach { updatedField ->
val index = reorderedFields.indexOfFirst { it.id == updatedField.id }
if (index >= 0 && reorderedFields[index] != updatedField) {
reorderedFields[index] = updatedField
}
}
}
Dialog(
@@ -1358,17 +1381,20 @@ fun ManageColumnsDialog(
field = field,
onEdit = { fieldToEdit = field },
onDelete = { fieldToDelete = field },
onDragStart = { _ ->
// Store the dragged item index if needed
},
onDragEnd = { fromIndex, toIndex ->
if (fromIndex != toIndex) {
val item = reorderedFields.removeAt(fromIndex)
reorderedFields.add(toIndex, item)
onMoveUp = {
if (index > 0) {
val item = reorderedFields.removeAt(index)
reorderedFields.add(index - 1, item)
}
},
currentIndex = index,
totalItems = reorderedFields.size
onMoveDown = {
if (index < reorderedFields.size - 1) {
val item = reorderedFields.removeAt(index)
reorderedFields.add(index + 1, item)
}
},
canMoveUp = index > 0,
canMoveDown = index < reorderedFields.size - 1
)
}
}
@@ -1456,52 +1482,17 @@ fun ColumnItem(
field: Field,
onEdit: () -> Unit,
onDelete: () -> Unit,
onDragStart: (Int) -> Unit,
onDragEnd: (Int, Int) -> Unit,
currentIndex: Int,
totalItems: Int
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
canMoveUp: Boolean,
canMoveDown: Boolean
) {
var isDragging by remember { mutableStateOf(false) }
var dragOffset by remember { mutableStateOf(0f) }
Card(
modifier = Modifier
.fillMaxWidth()
.offset { IntOffset(0, dragOffset.toInt()) }
.pointerInput(Unit) {
detectDragGestures(
onDragStart = {
isDragging = true
onDragStart(currentIndex)
},
onDragEnd = {
val itemHeight = 80.dp.toPx() // Approximate item height with spacing
val movedItems = (dragOffset / itemHeight).toInt()
val targetIndex = (currentIndex + movedItems).coerceIn(0, totalItems - 1)
onDragEnd(currentIndex, targetIndex)
isDragging = false
dragOffset = 0f
},
onDragCancel = {
isDragging = false
dragOffset = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragOffset += dragAmount.y
}
)
},
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isDragging)
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
else
MaterialTheme.colorScheme.surfaceVariant
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(
defaultElevation = if (isDragging) 8.dp else 1.dp
)
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
@@ -1510,13 +1501,37 @@ fun ColumnItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Drag handle on the left
Icon(
Icons.Default.Menu,
contentDescription = "Drag to reorder",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
// Move up/down buttons
Column {
IconButton(
onClick = onMoveUp,
enabled = canMoveUp,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.KeyboardArrowUp,
contentDescription = "Move up",
tint = if (canMoveUp)
MaterialTheme.colorScheme.onSurfaceVariant
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
IconButton(
onClick = onMoveDown,
enabled = canMoveDown,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = "Move down",
tint = if (canMoveDown)
MaterialTheme.colorScheme.onSurfaceVariant
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
}
Spacer(modifier = Modifier.width(12.dp))
+3 -204
View File
@@ -12,8 +12,7 @@
"better-sqlite3": "^11.0.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"sequelize": "^6.35.2"
"express": "^4.18.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
@@ -134,15 +133,6 @@
"@types/node": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"license": "MIT",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.24.tgz",
@@ -183,16 +173,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.23",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -259,12 +244,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/validator": {
"version": "13.15.3",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz",
"integrity": "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==",
"license": "MIT"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -696,12 +675,6 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dottie": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz",
"integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1104,15 +1077,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/inflection": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz",
"integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==",
"engines": [
"node >= 0.4.0"
],
"license": "MIT"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -1208,12 +1172,6 @@
"node": ">=0.12.0"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -1343,27 +1301,6 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1481,12 +1418,6 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -1651,12 +1582,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/retry-as-promised": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
"integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==",
"license": "MIT"
},
"node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
@@ -1748,100 +1673,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/sequelize": {
"version": "6.37.7",
"resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.7.tgz",
"integrity": "sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/sequelize"
}
],
"license": "MIT",
"dependencies": {
"@types/debug": "^4.1.8",
"@types/validator": "^13.7.17",
"debug": "^4.3.4",
"dottie": "^2.0.6",
"inflection": "^1.13.4",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"pg-connection-string": "^2.6.1",
"retry-as-promised": "^7.0.4",
"semver": "^7.5.4",
"sequelize-pool": "^7.1.0",
"toposort-class": "^1.0.1",
"uuid": "^8.3.2",
"validator": "^13.9.0",
"wkx": "^0.5.0"
},
"engines": {
"node": ">=10.0.0"
},
"peerDependenciesMeta": {
"ibm_db": {
"optional": true
},
"mariadb": {
"optional": true
},
"mysql2": {
"optional": true
},
"oracledb": {
"optional": true
},
"pg": {
"optional": true
},
"pg-hstore": {
"optional": true
},
"snowflake-sdk": {
"optional": true
},
"sqlite3": {
"optional": true
},
"tedious": {
"optional": true
}
}
},
"node_modules/sequelize-pool": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz",
"integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/sequelize/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/sequelize/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
@@ -2101,12 +1932,6 @@
"node": ">=0.6"
}
},
"node_modules/toposort-class": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz",
"integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==",
"license": "MIT"
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -2252,6 +2077,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
@@ -2278,15 +2104,6 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
@@ -2294,15 +2111,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/validator": {
"version": "13.15.15",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -2312,15 +2120,6 @@
"node": ">= 0.8"
}
},
"node_modules/wkx": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz",
"integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+7 -5
View File
@@ -179,19 +179,21 @@ router.post('/sync', async (req: Request, res: Response) => {
// Get updates from server since last sync
// If lastSyncTimestamp is 0, get all data (initial sync)
// Exclude deleted items from being sent
// IMPORTANT: Include deleted items so clients can sync deletions
let serverLists, serverFields, serverItems, serverItemValues;
if (lastSyncTimestamp === 0) {
// Initial sync: send all non-deleted items
serverLists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0').all();
serverFields = db.prepare('SELECT * FROM fields WHERE isDeleted = 0').all();
serverItems = db.prepare('SELECT * FROM items WHERE isDeleted = 0').all();
serverItemValues = db.prepare('SELECT * FROM item_values').all();
} else {
serverLists = db.prepare('SELECT * FROM lists WHERE updatedAt > ? AND isDeleted = 0').all(lastSyncTimestamp);
serverFields = db.prepare('SELECT * FROM fields WHERE updatedAt > ? AND isDeleted = 0').all(lastSyncTimestamp);
serverItems = db.prepare('SELECT * FROM items WHERE updatedAt > ? AND isDeleted = 0').all(lastSyncTimestamp);
serverItemValues = db.prepare('SELECT * FROM item_values WHERE updatedAt > ?').all(lastSyncTimestamp);
// Incremental sync: send all updates including deletions (use >= to include items updated exactly at lastSyncTimestamp)
serverLists = db.prepare('SELECT * FROM lists WHERE updatedAt >= ?').all(lastSyncTimestamp);
serverFields = db.prepare('SELECT * FROM fields WHERE updatedAt >= ?').all(lastSyncTimestamp);
serverItems = db.prepare('SELECT * FROM items WHERE updatedAt >= ?').all(lastSyncTimestamp);
serverItemValues = db.prepare('SELECT * FROM item_values WHERE updatedAt >= ?').all(lastSyncTimestamp);
}
// Convert isDeleted from INTEGER (0/1) to BOOLEAN