refactor: fix correctness bugs and simplify site picker code

- Rethrow CancellationException in goals query catch block to preserve
  structured concurrency
- Replace non-atomic delete+insert in updateSite with single UPDATE
  statement to prevent data loss on crash or site ID collision
- Add updateSiteId SQL query to StoredSite.sq
- Extract updateGroup helper on SitePickerUiState to deduplicate 8
  instances of the groups.map pattern (-66 lines)
- Remove unnecessary WHAT comments in SitePickerScreen/ViewModel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-07 12:51:17 +02:00
commit 50edfcdafb
5 changed files with 36 additions and 102 deletions

View file

@ -53,13 +53,10 @@ class SiteRepository @Inject constructor(
} }
fun updateSite(oldSiteId: String, newSiteId: String, instanceId: String) { fun updateSite(oldSiteId: String, newSiteId: String, instanceId: String) {
val sites = getSitesForInstance(instanceId) database.storedSiteQueries.updateSiteId(
val oldSortOrder = sites.find { it.id == oldSiteId }?.sortOrder?.toLong() ?: 0L
database.storedSiteQueries.delete(site_id = oldSiteId, instance_id = instanceId)
database.storedSiteQueries.insert(
site_id = newSiteId, site_id = newSiteId,
instance_id = instanceId, site_id_ = oldSiteId,
sort_order = oldSortOrder instance_id = instanceId
) )
} }

View file

@ -204,6 +204,8 @@ class StatsRepository @Inject constructor(
pagination = Pagination(limit = 10) pagination = Pagination(limit = 10)
) )
) )
} catch (e: kotlin.coroutines.cancellation.CancellationException) {
throw e
} catch (_: Exception) { } catch (_: Exception) {
// Goals may not be configured — return null instead of failing the whole dashboard // Goals may not be configured — return null instead of failing the whole dashboard
null null

View file

@ -68,7 +68,6 @@ fun SitePickerScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
// Site delete confirmation dialog
uiState.siteToDelete?.let { (instanceId, siteId) -> uiState.siteToDelete?.let { (instanceId, siteId) ->
AlertDialog( AlertDialog(
onDismissRequest = viewModel::dismissDeleteSite, onDismissRequest = viewModel::dismissDeleteSite,
@ -87,7 +86,6 @@ fun SitePickerScreen(
) )
} }
// Instance delete confirmation dialog
uiState.instanceToDelete?.let { instance -> uiState.instanceToDelete?.let { instance ->
val group = uiState.groups.find { it.instance.id == instance.id } val group = uiState.groups.find { it.instance.id == instance.id }
val siteCount = group?.sites?.size ?: 0 val siteCount = group?.sites?.size ?: 0
@ -175,7 +173,6 @@ fun SitePickerScreen(
) )
} }
// Expanded content: sites + add-site input
if (group.isExpanded) { if (group.isExpanded) {
items( items(
items = group.sites, items = group.sites,
@ -216,7 +213,6 @@ fun SitePickerScreen(
} }
} }
// Inline add-site input with validation
item(key = "add_${group.instance.id}") { item(key = "add_${group.instance.id}") {
AddSiteRow( AddSiteRow(
value = group.newSiteId, value = group.newSiteId,
@ -230,13 +226,11 @@ fun SitePickerScreen(
} }
} }
// Spacer between instance groups
item(key = "spacer_${group.instance.id}") { item(key = "spacer_${group.instance.id}") {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} }
// "Add new instance" button at bottom
item(key = "add_instance") { item(key = "add_instance") {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedButton( OutlinedButton(

View file

@ -35,7 +35,14 @@ data class SitePickerUiState(
val editSiteValue: String = "", val editSiteValue: String = "",
val siteToDelete: Pair<String, String>? = null, val siteToDelete: Pair<String, String>? = null,
val instanceToDelete: PlausibleInstance? = null val instanceToDelete: PlausibleInstance? = null
) {
fun updateGroup(
instanceId: String,
transform: (InstanceWithSites) -> InstanceWithSites
): SitePickerUiState = copy(
groups = groups.map { if (it.instance.id == instanceId) transform(it) else it }
) )
}
@HiltViewModel @HiltViewModel
class SitePickerViewModel @Inject constructor( class SitePickerViewModel @Inject constructor(
@ -79,27 +86,11 @@ class SitePickerViewModel @Inject constructor(
} }
fun toggleExpanded(instanceId: String) { fun toggleExpanded(instanceId: String) {
_uiState.update { state -> _uiState.update { it.updateGroup(instanceId) { g -> g.copy(isExpanded = !g.isExpanded) } }
state.copy(
groups = state.groups.map { group ->
if (group.instance.id == instanceId) {
group.copy(isExpanded = !group.isExpanded)
} else group
}
)
}
} }
fun onNewSiteIdChanged(instanceId: String, value: String) { fun onNewSiteIdChanged(instanceId: String, value: String) {
_uiState.update { state -> _uiState.update { it.updateGroup(instanceId) { g -> g.copy(newSiteId = value, siteTestError = null) } }
state.copy(
groups = state.groups.map { group ->
if (group.instance.id == instanceId) {
group.copy(newSiteId = value, siteTestError = null)
} else group
}
)
}
} }
/** /**
@ -112,16 +103,7 @@ class SitePickerViewModel @Inject constructor(
val siteId = group.newSiteId.trim() val siteId = group.newSiteId.trim()
if (siteId.isBlank() || group.isTestingSite) return if (siteId.isBlank() || group.isTestingSite) return
// Set testing state _uiState.update { it.updateGroup(instanceId) { g -> g.copy(isTestingSite = true, siteTestError = null) } }
_uiState.update { state ->
state.copy(
groups = state.groups.map { g ->
if (g.instance.id == instanceId) {
g.copy(isTestingSite = true, siteTestError = null)
} else g
}
)
}
viewModelScope.launch { viewModelScope.launch {
val instance = group.instance val instance = group.instance
@ -133,40 +115,19 @@ class SitePickerViewModel @Inject constructor(
) )
if (error != null) { if (error != null) {
// Validation failed — show error, don't add _uiState.update { it.updateGroup(instanceId) { g -> g.copy(isTestingSite = false, siteTestError = error) } }
_uiState.update { state ->
state.copy(
groups = state.groups.map { g ->
if (g.instance.id == instanceId) {
g.copy(isTestingSite = false, siteTestError = error)
} else g
}
)
}
return@launch return@launch
} }
// Validation passed — add the site
siteRepository.addSite(siteId, instanceId) siteRepository.addSite(siteId, instanceId)
val updatedSites = siteRepository.getSitesForInstance(instanceId) val updatedSites = siteRepository.getSitesForInstance(instanceId)
_uiState.update { state -> _uiState.update {
state.copy( it.updateGroup(instanceId) { g ->
groups = state.groups.map { g -> g.copy(sites = updatedSites, newSiteId = "", isTestingSite = false, siteTestError = null)
if (g.instance.id == instanceId) { }
g.copy(
sites = updatedSites,
newSiteId = "",
isTestingSite = false,
siteTestError = null
)
} else g
}
)
} }
} }
} }
// -- Site deletion --
fun requestDeleteSite(instanceId: String, siteId: String) { fun requestDeleteSite(instanceId: String, siteId: String) {
_uiState.update { it.copy(siteToDelete = instanceId to siteId) } _uiState.update { it.copy(siteToDelete = instanceId to siteId) }
@ -181,21 +142,13 @@ class SitePickerViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
siteRepository.removeSite(siteId, instanceId) siteRepository.removeSite(siteId, instanceId)
val updatedSites = siteRepository.getSitesForInstance(instanceId) val updatedSites = siteRepository.getSitesForInstance(instanceId)
_uiState.update { state -> _uiState.update {
state.copy( it.updateGroup(instanceId) { g -> g.copy(sites = updatedSites) }
groups = state.groups.map { g -> .copy(siteToDelete = null)
if (g.instance.id == instanceId) {
g.copy(sites = updatedSites)
} else g
},
siteToDelete = null
)
} }
} }
} }
// -- Instance deletion --
fun requestDeleteInstance(instance: PlausibleInstance) { fun requestDeleteInstance(instance: PlausibleInstance) {
_uiState.update { it.copy(instanceToDelete = instance) } _uiState.update { it.copy(instanceToDelete = instance) }
} }
@ -228,8 +181,6 @@ class SitePickerViewModel @Inject constructor(
} }
} }
// -- Site editing --
fun startEditing(siteId: String) { fun startEditing(siteId: String) {
_uiState.update { it.copy(editingSiteId = siteId, editSiteValue = siteId) } _uiState.update { it.copy(editingSiteId = siteId, editSiteValue = siteId) }
} }
@ -250,16 +201,9 @@ class SitePickerViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
siteRepository.updateSite(oldId, newId, instanceId) siteRepository.updateSite(oldId, newId, instanceId)
val updatedSites = siteRepository.getSitesForInstance(instanceId) val updatedSites = siteRepository.getSitesForInstance(instanceId)
_uiState.update { s -> _uiState.update {
s.copy( it.updateGroup(instanceId) { g -> g.copy(sites = updatedSites) }
groups = s.groups.map { g -> .copy(editingSiteId = null, editSiteValue = "")
if (g.instance.id == instanceId) {
g.copy(sites = updatedSites)
} else g
},
editingSiteId = null,
editSiteValue = ""
)
} }
} }
} }
@ -268,24 +212,18 @@ class SitePickerViewModel @Inject constructor(
_uiState.update { it.copy(editingSiteId = null, editSiteValue = "") } _uiState.update { it.copy(editingSiteId = null, editSiteValue = "") }
} }
// -- Site reordering --
/** /**
* Move a site within an instance's list. Updates UI state immediately * Move a site within an instance's list. Updates UI state immediately
* for responsive drag feedback persisted on drag end via saveSiteOrder. * for responsive drag feedback persisted on drag end via saveSiteOrder.
*/ */
fun moveSite(instanceId: String, fromIndex: Int, toIndex: Int) { fun moveSite(instanceId: String, fromIndex: Int, toIndex: Int) {
_uiState.update { state -> _uiState.update {
state.copy( it.updateGroup(instanceId) { group ->
groups = state.groups.map { group ->
if (group.instance.id == instanceId) {
val reordered = group.sites.toMutableList().apply { val reordered = group.sites.toMutableList().apply {
add(toIndex, removeAt(fromIndex)) add(toIndex, removeAt(fromIndex))
} }
group.copy(sites = reordered) group.copy(sites = reordered)
} else group
} }
)
} }
} }

View file

@ -27,3 +27,6 @@ UPDATE stored_sites SET sort_order = ? WHERE site_id = ? AND instance_id = ?;
maxSortOrderForInstance: maxSortOrderForInstance:
SELECT MAX(sort_order) AS max_order FROM stored_sites WHERE instance_id = ?; SELECT MAX(sort_order) AS max_order FROM stored_sites WHERE instance_id = ?;
updateSiteId:
UPDATE stored_sites SET site_id = ? WHERE site_id = ? AND instance_id = ?;