feat: implement soft delete functionality for lists, fields, and items with cascading updates
This commit is contained in:
@@ -34,6 +34,12 @@ interface FieldDao {
|
|||||||
timestamp: Long,
|
timestamp: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Query("UPDATE fields SET isDeleted = 1, updatedAt = :timestamp WHERE listId = :listId")
|
||||||
|
suspend fun softDeleteFieldsByList(
|
||||||
|
listId: String,
|
||||||
|
timestamp: Long,
|
||||||
|
)
|
||||||
|
|
||||||
@Query("DELETE FROM fields WHERE id = :fieldId")
|
@Query("DELETE FROM fields WHERE id = :fieldId")
|
||||||
suspend fun deleteField(fieldId: String)
|
suspend fun deleteField(fieldId: String)
|
||||||
|
|
||||||
|
|||||||
@@ -45,4 +45,10 @@ interface ItemDao {
|
|||||||
// Get all items updated since timestamp, including deleted ones for sync
|
// Get all items updated since timestamp, including deleted ones for sync
|
||||||
@Query("SELECT * FROM items WHERE updatedAt >= :since")
|
@Query("SELECT * FROM items WHERE updatedAt >= :since")
|
||||||
suspend fun getItemsUpdatedSince(since: Long): List<Item>
|
suspend fun getItemsUpdatedSince(since: Long): List<Item>
|
||||||
|
|
||||||
|
@Query("UPDATE items SET isDeleted = 1, updatedAt = :timestamp WHERE listId = :listId")
|
||||||
|
suspend fun softDeleteItemsByList(
|
||||||
|
listId: String,
|
||||||
|
timestamp: Long,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ fun ListsScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefsLocal = remember { PreferencesManager.getInstance(context) }
|
val prefsLocal = remember { PreferencesManager.getInstance(context) }
|
||||||
ConnectionStatusAction(prefs = prefsLocal)
|
ConnectionStatusAction(prefs = prefsLocal)
|
||||||
SortMenu(prefs = prefs)
|
|
||||||
IconButton(onClick = onNavigateToSettings) {
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Settings,
|
Icons.Default.Settings,
|
||||||
@@ -201,6 +200,18 @@ fun ListsScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Controls above the list (align with table view UX)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
SortMenu(prefs = prefs)
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -234,6 +245,7 @@ fun ListsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showCreateDialog) {
|
if (showCreateDialog) {
|
||||||
CreateListDialog(
|
CreateListDialog(
|
||||||
|
|||||||
+6
-1
@@ -156,7 +156,12 @@ class ListsViewModel(
|
|||||||
val list = database.listDao().getListById(listId)
|
val list = database.listDao().getListById(listId)
|
||||||
if (list != null) {
|
if (list != null) {
|
||||||
Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"")
|
Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"")
|
||||||
database.listDao().softDeleteList(listId, System.currentTimeMillis())
|
val ts = System.currentTimeMillis()
|
||||||
|
// Soft-delete the list, and cascade soft-deletes to its fields and items so sync uploads tombstones
|
||||||
|
database.listDao().softDeleteList(listId, ts)
|
||||||
|
// Cascade locally to ensure children are marked deleted and uploaded on next sync
|
||||||
|
database.fieldDao().softDeleteFieldsByList(listId, ts)
|
||||||
|
database.itemDao().softDeleteItemsByList(listId, ts)
|
||||||
// Sync immediately after deleting
|
// Sync immediately after deleting
|
||||||
performSync()
|
performSync()
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-153
@@ -1,7 +1,6 @@
|
|||||||
package com.collabtable.app.ui.screens
|
package com.collabtable.app.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -37,14 +36,12 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import com.collabtable.app.ui.components.ConnectionStatusAction
|
import com.collabtable.app.ui.components.ConnectionStatusAction
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -61,11 +58,7 @@ import com.collabtable.app.utils.Logger
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import android.os.Build
|
|
||||||
import android.net.Uri
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -90,50 +83,7 @@ fun SettingsScreen(
|
|||||||
var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) }
|
var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) }
|
||||||
var syncIntervalError by remember { mutableStateOf<String?>(null) }
|
var syncIntervalError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
// Test connection state
|
// Removed test connectivity logic; the connection indicator is shown via ConnectionStatusAction
|
||||||
var isTesting by remember { mutableStateOf(false) }
|
|
||||||
var testOk by remember { mutableStateOf<Boolean?>(null) }
|
|
||||||
var testMessage by remember { mutableStateOf<String?>(null) }
|
|
||||||
var lastLatencyMs by remember { mutableStateOf<Long?>(null) }
|
|
||||||
|
|
||||||
val testClient = remember {
|
|
||||||
OkHttpClient.Builder()
|
|
||||||
.connectTimeout(5, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(5, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isEmulator(): Boolean {
|
|
||||||
val fp = Build.FINGERPRINT.lowercase()
|
|
||||||
val model = Build.MODEL.lowercase()
|
|
||||||
val brand = Build.BRAND.lowercase()
|
|
||||||
val device = Build.DEVICE.lowercase()
|
|
||||||
val product = Build.PRODUCT.lowercase()
|
|
||||||
return fp.contains("generic") || fp.contains("emulator") ||
|
|
||||||
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") ||
|
|
||||||
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toHealthUrl(rawApiUrl: String): String {
|
|
||||||
// Ensure we hit the unauthenticated /health endpoint
|
|
||||||
var url = rawApiUrl
|
|
||||||
// Emu remap for local hosts
|
|
||||||
try {
|
|
||||||
val uri = Uri.parse(url)
|
|
||||||
val host = uri.host?.lowercase()
|
|
||||||
val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal")
|
|
||||||
val scheme = uri.scheme ?: "http"
|
|
||||||
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
|
||||||
val base = if (needsRemap) "$scheme://10.0.2.2$portPart" else "$scheme://${uri.host ?: ""}$portPart"
|
|
||||||
// Replace any /api or /api/ segment at end with /health
|
|
||||||
val path = (uri.encodedPath ?: "/").trimEnd('/')
|
|
||||||
val healthPath = if (path.endsWith("/api")) "/health" else if (path.endsWith("/api/")) "/health" else "/health"
|
|
||||||
return base + healthPath
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Fallback: naive replace
|
|
||||||
return rawApiUrl.replace("/api/", "/health").replace("/api", "/health")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -146,9 +96,6 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
ConnectionStatusAction(prefs = preferencesManager)
|
ConnectionStatusAction(prefs = preferencesManager)
|
||||||
IconButton(onClick = onNavigateToLogs) {
|
|
||||||
Icon(Icons.Default.List, contentDescription = "Logs")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
colors =
|
colors =
|
||||||
TopAppBarDefaults.topAppBarColors(
|
TopAppBarDefaults.topAppBarColors(
|
||||||
@@ -350,93 +297,28 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection test
|
// Diagnostics / Logs
|
||||||
|
Text(
|
||||||
|
text = "Diagnostics",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors =
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Server connectivity",
|
text = "View application logs",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Row(
|
Button(onClick = onNavigateToLogs, modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Icon(Icons.Default.List, contentDescription = null)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
isTesting = true
|
|
||||||
testOk = null
|
|
||||||
testMessage = null
|
|
||||||
lastLatencyMs = null
|
|
||||||
coroutineScope.launch {
|
|
||||||
val rawApi = preferencesManager.getServerUrl()
|
|
||||||
val healthUrl = toHealthUrl(rawApi)
|
|
||||||
val req = Request.Builder().url(healthUrl).get().build()
|
|
||||||
val start = System.nanoTime()
|
|
||||||
try {
|
|
||||||
val resp = withContext(Dispatchers.IO) { testClient.newCall(req).execute() }
|
|
||||||
val tookMs = (System.nanoTime() - start) / 1_000_000
|
|
||||||
if (resp.isSuccessful) {
|
|
||||||
testOk = true
|
|
||||||
lastLatencyMs = tookMs
|
|
||||||
testMessage = "Reachable (${tookMs} ms)"
|
|
||||||
} else {
|
|
||||||
testOk = false
|
|
||||||
testMessage = "HTTP ${resp.code}"
|
|
||||||
}
|
|
||||||
resp.close()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
testOk = false
|
|
||||||
testMessage = e.message ?: "Connection error"
|
|
||||||
} finally {
|
|
||||||
isTesting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isTesting,
|
|
||||||
) {
|
|
||||||
if (isTesting) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Testing…")
|
Text("Open Logs")
|
||||||
} else {
|
|
||||||
Text("Test connection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when (testOk) {
|
|
||||||
true -> Text(
|
|
||||||
text = testMessage ?: "Reachable",
|
|
||||||
color = Color(0xFF2E7D32),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
false -> Text(
|
|
||||||
text = testMessage ?: "Not reachable",
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
null -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (testOk == false) {
|
|
||||||
Text(
|
|
||||||
text = "Tip: On emulator use http://10.0.2.2:PORT/api/. On a device use your PC's LAN IP.",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,29 +431,7 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform a silent health check when entering the screen to set the indicator
|
// Test connectivity removed; connection status is shown in the top app bar indicator
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
try {
|
|
||||||
val rawApi = preferencesManager.getServerUrl()
|
|
||||||
val healthUrl = toHealthUrl(rawApi)
|
|
||||||
val req = Request.Builder().url(healthUrl).get().build()
|
|
||||||
val start = System.nanoTime()
|
|
||||||
val resp = withContext(Dispatchers.IO) { testClient.newCall(req).execute() }
|
|
||||||
val tookMs = (System.nanoTime() - start) / 1_000_000
|
|
||||||
if (resp.isSuccessful) {
|
|
||||||
testOk = true
|
|
||||||
lastLatencyMs = tookMs
|
|
||||||
testMessage = "Reachable (${tookMs} ms)"
|
|
||||||
} else {
|
|
||||||
testOk = false
|
|
||||||
testMessage = "HTTP ${resp.code}"
|
|
||||||
}
|
|
||||||
resp.close()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
testOk = false
|
|
||||||
testMessage = e.message ?: "Connection error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
private fun formatServerUrlForDisplay(raw: String): String {
|
private fun formatServerUrlForDisplay(raw: String): String {
|
||||||
var s = raw.trim()
|
var s = raw.trim()
|
||||||
|
|||||||
@@ -122,6 +122,18 @@ router.post('/sync', async (req: Request, res: Response) => {
|
|||||||
list.updatedAt,
|
list.updatedAt,
|
||||||
list.isDeleted ? 1 : 0
|
list.isDeleted ? 1 : 0
|
||||||
]);
|
]);
|
||||||
|
// If a list was deleted, cascade the deletion to child records on server
|
||||||
|
// - mark fields and items as deleted (tombstones) so other clients learn about them
|
||||||
|
// - remove item_values belonging to items under this list (no tombstone support for values)
|
||||||
|
if (list.isDeleted) {
|
||||||
|
try {
|
||||||
|
await tx.execute('UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]);
|
||||||
|
await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]);
|
||||||
|
await tx.execute('DELETE FROM item_values WHERE itemId IN (SELECT id FROM items WHERE listId = ?)', [list.id]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SYNC] Cascade delete for list failed:', String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +150,14 @@ router.post('/sync', async (req: Request, res: Response) => {
|
|||||||
field.updatedAt,
|
field.updatedAt,
|
||||||
field.isDeleted ? 1 : 0
|
field.isDeleted ? 1 : 0
|
||||||
]);
|
]);
|
||||||
|
// If a field was deleted, remove any item_values referencing it so clients converge
|
||||||
|
if (field.isDeleted) {
|
||||||
|
try {
|
||||||
|
await tx.execute('DELETE FROM item_values WHERE fieldId = ?', [field.id]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SYNC] Cascade delete for field failed:', String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +170,14 @@ router.post('/sync', async (req: Request, res: Response) => {
|
|||||||
item.updatedAt,
|
item.updatedAt,
|
||||||
item.isDeleted ? 1 : 0
|
item.isDeleted ? 1 : 0
|
||||||
]);
|
]);
|
||||||
|
// If an item was deleted, remove its values (no tombstone support for values)
|
||||||
|
if (item.isDeleted) {
|
||||||
|
try {
|
||||||
|
await tx.execute('DELETE FROM item_values WHERE itemId = ?', [item.id]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SYNC] Cascade delete for item failed:', String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ router.get('/', (req: Request, res: Response) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="list-id">ID: \${list.id}</div>
|
<div class="list-id">ID: \${list.id}</div>
|
||||||
<div class="list-id">Created: \${fmtDate(list.createdAt)}</div>
|
<div class="list-id">Created: \${fmtDate(list.createdAt)}</div>
|
||||||
<div class="list-id">Updated: \${fmtDate(list.updatedAt)}</div>
|
<div class="list-id">Updated: \${fmtDate(list.effectiveUpdatedAt ?? list.updatedAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ router.get('/web/data', async (req: Request, res: Response) => {
|
|||||||
};
|
};
|
||||||
// Normalize key casing across SQLite (camelCase) and Postgres (lowercase)
|
// Normalize key casing across SQLite (camelCase) and Postgres (lowercase)
|
||||||
const pick = (obj: any, a: string, b: string) => obj[a] ?? obj[b];
|
const pick = (obj: any, a: string, b: string) => obj[a] ?? obj[b];
|
||||||
const formattedLists = (lists as any[]).map(list => ({
|
let formattedLists = (lists as any[]).map(list => ({
|
||||||
...list,
|
...list,
|
||||||
isDeleted: !!pick(list, 'isDeleted', 'isdeleted'),
|
isDeleted: !!pick(list, 'isDeleted', 'isdeleted'),
|
||||||
createdAt: toMillis(pick(list, 'createdAt', 'createdat')),
|
createdAt: toMillis(pick(list, 'createdAt', 'createdat')),
|
||||||
@@ -394,6 +394,42 @@ router.get('/web/data', async (req: Request, res: Response) => {
|
|||||||
fieldId: pick(v, 'fieldId', 'fieldid'),
|
fieldId: pick(v, 'fieldId', 'fieldid'),
|
||||||
updatedAt: toMillis(pick(v, 'updatedAt', 'updatedat'))
|
updatedAt: toMillis(pick(v, 'updatedAt', 'updatedat'))
|
||||||
}));
|
}));
|
||||||
|
// Compute effective updatedAt for lists based on latest change in the list itself or its children
|
||||||
|
const itemIdToListId = new Map<string, string>();
|
||||||
|
for (const it of formattedItems as any[]) {
|
||||||
|
if (it && it.id && it.listId) itemIdToListId.set(it.id, it.listId);
|
||||||
|
}
|
||||||
|
const listToMaxField = new Map<string, number>();
|
||||||
|
for (const f of formattedFields as any[]) {
|
||||||
|
if (!f || !f.listId) continue;
|
||||||
|
const t = typeof f.updatedAt === 'number' ? f.updatedAt : 0;
|
||||||
|
const prev = listToMaxField.get(f.listId) || 0;
|
||||||
|
if (t > prev) listToMaxField.set(f.listId, t);
|
||||||
|
}
|
||||||
|
const listToMaxItem = new Map<string, number>();
|
||||||
|
for (const it of formattedItems as any[]) {
|
||||||
|
if (!it || !it.listId) continue;
|
||||||
|
const t = typeof it.updatedAt === 'number' ? it.updatedAt : 0;
|
||||||
|
const prev = listToMaxItem.get(it.listId) || 0;
|
||||||
|
if (t > prev) listToMaxItem.set(it.listId, t);
|
||||||
|
}
|
||||||
|
const listToMaxValue = new Map<string, number>();
|
||||||
|
for (const val of formattedValues as any[]) {
|
||||||
|
if (!val || !val.itemId) continue;
|
||||||
|
const lId = itemIdToListId.get(val.itemId);
|
||||||
|
if (!lId) continue;
|
||||||
|
const t = typeof val.updatedAt === 'number' ? val.updatedAt : 0;
|
||||||
|
const prev = listToMaxValue.get(lId) || 0;
|
||||||
|
if (t > prev) listToMaxValue.set(lId, t);
|
||||||
|
}
|
||||||
|
formattedLists = formattedLists.map((l: any) => {
|
||||||
|
const fMax = listToMaxField.get(l.id) || 0;
|
||||||
|
const iMax = listToMaxItem.get(l.id) || 0;
|
||||||
|
const vMax = listToMaxValue.get(l.id) || 0;
|
||||||
|
const base = typeof l.updatedAt === 'number' ? l.updatedAt : 0;
|
||||||
|
const eff = Math.max(base, fMax, iMax, vMax);
|
||||||
|
return { ...l, effectiveUpdatedAt: eff === 0 ? l.updatedAt : eff };
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
lists: formattedLists,
|
lists: formattedLists,
|
||||||
|
|||||||
Reference in New Issue
Block a user