enable display of weekly stats

fixes #2946

Weekly Scores and Ranks

Weekly Scores and Ranks
I've played SC with a friend and she was quite disappointed that she can't "beat" me, cause I had a headstart of month.

I was wondering if the game and competition factor wouldn't work better if we had weekly stats instead of epoch ones.

We currently have a lot of empty real estate on the profile page:

![Screenshot_20210604-153309769](https://user-images.githubusercontent.com/614929/120810065-05798080-c54b-11eb-82d9-4a8521c86a69.jpg)

Maybe we could move the current stats down to the bottom and have a weekly counter in the top for the current week and show the results of the last week below.

This way friends can compete which eachother on a weekly basis.

If I'm correct, this might also require modifications to the [web server statistics](https://github.com/streetcomplete/sc-statistics-service), as well as the main app.  Anyway, it seems a reasonable idea.  I wonder what other statistics one might include.
We could in theory query the OSM profile and get the OSM friends and their stats couldn't be shown there. 🤔
Technically, this is well possible and actually not that much effort. I would accept a PR that implements this. Actually, it would need to be two PRs, one for the UI here, one for the generation of the stats, as @10992-osm correctly mentions.
As a first version I would try to do something like the following. While there is definitely room for improving the design, I think this should be sufficient as a first step.

![profile](https://user-images.githubusercontent.com/32853499/138177423-c9e81183-303c-4a48-9985-63b5241d01ec.png)

Hmm, why show last week? You can't change anything from last week, I think it might be better to only show things that the user can improve (by contributing). If it is to show a (positive?) change, there is also another issue/idea to animate the changes since one last had a look in the profile screen.
Maybe you can replace "last week" with "current month"
> Hmm, why show last week?

I thought about it as "the results of last weeks competition". You might be right though, I like "current month" as well.

./gradlew :app:testDebugUnitTest --tests "de.westnordost.streetcomplete.data.user.statistics.StatisticsParserTest" --tests "de.westnordost.streetcomplete.data.user.statistics.StatisticsControllerTest"

diff --git a/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt b/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt
index f3769f6a9e3..5c2d67930ac 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt
@@ -24,6 +24,7 @@ object Prefs {
     const val OSM_LOGGED_IN_AFTER_OAUTH_FUCKUP = "osm.logged_in_after_oauth_fuckup"
     const val USER_DAYS_ACTIVE = "days_active"
     const val USER_GLOBAL_RANK = "user_global_rank"
+    const val USER_GLOBAL_RANK_CURRENT_WEEK = "user_global_rank_current_week"
     const val USER_LAST_TIMESTAMP_ACTIVE = "last_timestamp_active"
     const val IS_SYNCHRONIZING_STATISTICS = "is_synchronizing_statistics"
     const val TEAM_MODE_INDEX_IN_TEAM = "team_mode.index_in_team"
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt b/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt
index 7e74bdbb485..ba01cb63072 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt
@@ -20,8 +20,8 @@ import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable
 import de.westnordost.streetcomplete.data.osmnotes.notequests.NoteQuestsHiddenTable
 import de.westnordost.streetcomplete.data.user.achievements.UserAchievementsTable
 import de.westnordost.streetcomplete.data.user.achievements.UserLinksTable
-import de.westnordost.streetcomplete.data.user.statistics.CountryStatisticsTable
-import de.westnordost.streetcomplete.data.user.statistics.EditTypeStatisticsTable
+import de.westnordost.streetcomplete.data.user.statistics.CountryStatisticsTables
+import de.westnordost.streetcomplete.data.user.statistics.EditTypeStatisticsTables
 import de.westnordost.streetcomplete.data.visiblequests.QuestPresetsTable
 import de.westnordost.streetcomplete.data.visiblequests.QuestTypeOrderTable
 import de.westnordost.streetcomplete.data.visiblequests.VisibleQuestTypeTable
@@ -84,8 +84,10 @@ class StreetCompleteSQLiteOpenHelper(context: Context, dbName: String) :
         db.execSQL(DownloadedTilesTable.CREATE)

         // user statistics
-        db.execSQL(EditTypeStatisticsTable.CREATE)
-        db.execSQL(CountryStatisticsTable.CREATE)
+        db.execSQL(EditTypeStatisticsTables.create(EditTypeStatisticsTables.NAME))
+        db.execSQL(EditTypeStatisticsTables.create(EditTypeStatisticsTables.NAME_CURRENT_WEEK))
+        db.execSQL(CountryStatisticsTables.create(CountryStatisticsTables.NAME))
+        db.execSQL(CountryStatisticsTables.create(CountryStatisticsTables.NAME_CURRENT_WEEK))
         db.execSQL(UserAchievementsTable.CREATE)
         db.execSQL(UserLinksTable.CREATE)

@@ -174,7 +176,11 @@ class StreetCompleteSQLiteOpenHelper(context: Context, dbName: String) :
         if (oldVersion <= 5 && newVersion > 5) {
             db.execSQL("ALTER TABLE ${NoteEditsTable.NAME} ADD COLUMN ${NoteEditsTable.Columns.TRACK} text DEFAULT '[]' NOT NULL")
         }
+        if (oldVersion <= 6 && newVersion > 6) {
+            db.execSQL(EditTypeStatisticsTables.create(EditTypeStatisticsTables.NAME_CURRENT_WEEK))
+            db.execSQL(CountryStatisticsTables.create(CountryStatisticsTables.NAME_CURRENT_WEEK))
+        }
     }
 }

-private const val DB_VERSION = 6
+private const val DB_VERSION = 7
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsDao.kt
index 0e3b0cc9f7b..6f73a0f9368 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsDao.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsDao.kt
@@ -2,29 +2,28 @@ package de.westnordost.streetcomplete.data.user.statistics

 import de.westnordost.streetcomplete.data.CursorPosition
 import de.westnordost.streetcomplete.data.Database
-import de.westnordost.streetcomplete.data.user.statistics.CountryStatisticsTable.Columns.COUNTRY_CODE
-import de.westnordost.streetcomplete.data.user.statistics.CountryStatisticsTable.Columns.RANK
-import de.westnordost.streetcomplete.data.user.statistics.CountryStatisticsTable.Columns.SUCCEEDED
-import de.westnordost.streetcomplete.data.user.statistics.CountryStatisticsTable.NAME
+import de.westnordost.streetcomplete.data.user.statistics.CountryStatisticsTables.Columns.COUNTRY_CODE
+import de.westnordost.streetcomplete.data.user.statistics.CountryStatisticsTables.Columns.RANK
+import de.westnordost.streetcomplete.data.user.statistics.CountryStatisticsTables.Columns.SUCCEEDED

 /** Stores how many quests the user solved in which country */
-class CountryStatisticsDao(private val db: Database) {
+class CountryStatisticsDao(private val db: Database, private val name: String) {

     fun getCountryWithBiggestSolvedCount(): CountryStatistics? =
-        db.queryOne(NAME, orderBy = "$SUCCEEDED DESC") { it.toCountryStatistics() }
+        db.queryOne(name, orderBy = "$SUCCEEDED DESC") { it.toCountryStatistics() }

     fun getAll(): List<CountryStatistics> =
-        db.query(NAME) { it.toCountryStatistics() }
+        db.query(name) { it.toCountryStatistics() }

     fun clear() {
-        db.delete(NAME)
+        db.delete(name)
     }

     fun replaceAll(countriesStatistics: Collection<CountryStatistics>) {
         db.transaction {
-            db.delete(NAME)
+            db.delete(name)
             if (countriesStatistics.isNotEmpty()) {
-                db.replaceMany(NAME,
+                db.replaceMany(name,
                     arrayOf(COUNTRY_CODE, SUCCEEDED, RANK),
                     countriesStatistics.map { arrayOf(it.countryCode, it.count, it.rank) }
                 )
@@ -35,18 +34,18 @@ class CountryStatisticsDao(private val db: Database) {
     fun addOne(countryCode: String) {
         db.transaction {
             // first ensure the row exists
-            db.insertOrIgnore(NAME, listOf(
+            db.insertOrIgnore(name, listOf(
                 COUNTRY_CODE to countryCode,
                 SUCCEEDED to 0
             ))

             // then increase by one
-            db.exec("UPDATE $NAME SET $SUCCEEDED = $SUCCEEDED + 1 WHERE $COUNTRY_CODE = ?", arrayOf(countryCode))
+            db.exec("UPDATE $name SET $SUCCEEDED = $SUCCEEDED + 1 WHERE $COUNTRY_CODE = ?", arrayOf(countryCode))
         }
     }

     fun subtractOne(countryCode: String) {
-        db.exec("UPDATE $NAME SET $SUCCEEDED = $SUCCEEDED - 1 WHERE $COUNTRY_CODE = ?", arrayOf(countryCode))
+        db.exec("UPDATE $name SET $SUCCEEDED = $SUCCEEDED - 1 WHERE $COUNTRY_CODE = ?", arrayOf(countryCode))
     }
 }

diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsTable.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsTables.kt
similarity index 71%
rename from app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsTable.kt
rename to app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsTables.kt
index 2eab3657a46..b86faf31dda 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsTable.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsTables.kt
@@ -1,7 +1,8 @@
 package de.westnordost.streetcomplete.data.user.statistics

-object CountryStatisticsTable {
+object CountryStatisticsTables {
     const val NAME = "country_statistics"
+    const val NAME_CURRENT_WEEK = "country_statistics_current_week"

     object Columns {
         const val COUNTRY_CODE = "country_code"
@@ -9,8 +10,8 @@ object CountryStatisticsTable {
         const val RANK = "rank"
     }

-    const val CREATE = """
-        CREATE TABLE $NAME (
+    fun create(name: String) = """
+        CREATE TABLE $name (
             ${Columns.COUNTRY_CODE} varchar(255) PRIMARY KEY,
             ${Columns.SUCCEEDED} int NOT NULL,
             ${Columns.RANK} int
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsDao.kt
index cb09a86ce6d..2ac7f877590 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsDao.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsDao.kt
@@ -2,28 +2,27 @@ package de.westnordost.streetcomplete.data.user.statistics

 import de.westnordost.streetcomplete.data.CursorPosition
 import de.westnordost.streetcomplete.data.Database
-import de.westnordost.streetcomplete.data.user.statistics.EditTypeStatisticsTable.Columns.ELEMENT_EDIT_TYPE
-import de.westnordost.streetcomplete.data.user.statistics.EditTypeStatisticsTable.Columns.SUCCEEDED
-import de.westnordost.streetcomplete.data.user.statistics.EditTypeStatisticsTable.NAME
+import de.westnordost.streetcomplete.data.user.statistics.EditTypeStatisticsTables.Columns.ELEMENT_EDIT_TYPE
+import de.westnordost.streetcomplete.data.user.statistics.EditTypeStatisticsTables.Columns.SUCCEEDED

 /** Stores how many edits of which element type the user did */
-class EditTypeStatisticsDao(private val db: Database) {
+class EditTypeStatisticsDao(private val db: Database, private val name: String) {

     fun getTotalAmount(): Int =
-        db.queryOne(NAME, arrayOf("total($SUCCEEDED) as count")) { it.getInt("count") } ?: 0
+        db.queryOne(name, arrayOf("total($SUCCEEDED) as count")) { it.getInt("count") } ?: 0

     fun getAll(): List<EditTypeStatistics> =
-        db.query(NAME) { it.toEditTypeStatistics() }
+        db.query(name) { it.toEditTypeStatistics() }

     fun clear() {
-        db.delete(NAME)
+        db.delete(name)
     }

     fun replaceAll(amounts: Map<String, Int>) {
         db.transaction {
-            db.delete(NAME)
+            db.delete(name)
             if (amounts.isNotEmpty()) {
-                db.replaceMany(NAME,
+                db.replaceMany(name,
                     arrayOf(ELEMENT_EDIT_TYPE, SUCCEEDED),
                     amounts.map { arrayOf(it.key, it.value) }
                 )
@@ -34,22 +33,22 @@ class EditTypeStatisticsDao(private val db: Database) {
     fun addOne(type: String) {
         db.transaction {
             // first ensure the row exists
-            db.insertOrIgnore(NAME, listOf(
+            db.insertOrIgnore(name, listOf(
                 ELEMENT_EDIT_TYPE to type,
                 SUCCEEDED to 0
             ))

             // then increase by one
-            db.exec("UPDATE $NAME SET $SUCCEEDED = $SUCCEEDED + 1 WHERE $ELEMENT_EDIT_TYPE = ?", arrayOf(type))
+            db.exec("UPDATE $name SET $SUCCEEDED = $SUCCEEDED + 1 WHERE $ELEMENT_EDIT_TYPE = ?", arrayOf(type))
         }
     }

     fun subtractOne(type: String) {
-        db.exec("UPDATE $NAME SET $SUCCEEDED = $SUCCEEDED - 1 WHERE $ELEMENT_EDIT_TYPE = ?", arrayOf(type))
+        db.exec("UPDATE $name SET $SUCCEEDED = $SUCCEEDED - 1 WHERE $ELEMENT_EDIT_TYPE = ?", arrayOf(type))
     }

     fun getAmount(type: String): Int =
-        db.queryOne(NAME,
+        db.queryOne(name,
             columns = arrayOf(SUCCEEDED),
             where = "$ELEMENT_EDIT_TYPE = ?",
             args = arrayOf(type)
@@ -57,7 +56,7 @@ class EditTypeStatisticsDao(private val db: Database) {

     fun getAmount(type: List<String>): Int {
         val questionMarks = Array(type.size) { "?" }.joinToString(",")
-        return db.queryOne(NAME,
+        return db.queryOne(name,
             columns = arrayOf("total($SUCCEEDED) as count"),
             where = "$ELEMENT_EDIT_TYPE in ($questionMarks)",
             args = type.toTypedArray()
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsTable.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsTables.kt
similarity index 68%
rename from app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsTable.kt
rename to app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsTables.kt
index aa89e3b3fae..dea725751ff 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsTable.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsTables.kt
@@ -1,15 +1,16 @@
 package de.westnordost.streetcomplete.data.user.statistics

-object EditTypeStatisticsTable {
+object EditTypeStatisticsTables {
     const val NAME = "quest_statistics"
+    const val NAME_CURRENT_WEEK = "quest_statistics_current_week"

     object Columns {
         const val ELEMENT_EDIT_TYPE = "quest_type"
         const val SUCCEEDED = "succeeded"
     }

-    const val CREATE = """
-        CREATE TABLE $NAME (
+    fun create(name: String) = """
+        CREATE TABLE $name (
             ${Columns.ELEMENT_EDIT_TYPE} varchar(255) PRIMARY KEY,
             ${Columns.SUCCEEDED} int NOT NULL
         );
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/Statistics.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/Statistics.kt
index b6766d3d7c3..f06a7e10311 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/Statistics.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/Statistics.kt
@@ -5,8 +5,11 @@ data class Statistics(
     val countries: List<CountryStatistics>,
     val rank: Int,
     val daysActive: Int,
+    val currentWeekRank: Int,
+    val currentWeekTypes: List<EditTypeStatistics>,
+    val currentWeekCountries: List<CountryStatistics>,
     val lastUpdate: Long,
-    val isAnalyzing: Boolean
+    val isAnalyzing: Boolean,
 )

 data class CountryStatistics(val countryCode: String, val count: Int, val rank: Int?)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsController.kt
index 384e36c8048..90ed28561f3 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsController.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsController.kt
@@ -19,6 +19,8 @@ import java.util.concurrent.FutureTask
 class StatisticsController(
     private val editTypeStatisticsDao: EditTypeStatisticsDao,
     private val countryStatisticsDao: CountryStatisticsDao,
+    private val currentWeekEditTypeStatisticsDao: EditTypeStatisticsDao,
+    private val currentWeekCountryStatisticsDao: CountryStatisticsDao,
     private val countryBoundaries: FutureTask<CountryBoundaries>,
     private val prefs: SharedPreferences,
     userLoginStatusSource: UserLoginStatusSource
@@ -45,6 +47,12 @@ class StatisticsController(
             prefs.edit(true) { putInt(Prefs.USER_DAYS_ACTIVE, value) }
         }

+    override var currentWeekRank: Int
+        get() = prefs.getInt(Prefs.USER_GLOBAL_RANK_CURRENT_WEEK, -1)
+        private set(value) {
+            prefs.edit(true) { putInt(Prefs.USER_GLOBAL_RANK_CURRENT_WEEK, value) }
+        }
+
     override var isSynchronizing: Boolean
         // default true because if it is not set yet, the first thing that is done is to synchronize it
         get() = prefs.getBoolean(Prefs.IS_SYNCHRONIZING_STATISTICS, true)
@@ -80,16 +88,36 @@ class StatisticsController(
     override fun getCountryStatisticsOfCountryWithBiggestSolvedCount() =
         countryStatisticsDao.getCountryWithBiggestSolvedCount()

+    override fun getCurrentWeekEditCount(): Int =
+        currentWeekEditTypeStatisticsDao.getTotalAmount()
+
+    override fun getCurrentWeekEditTypeStatistics(): List<EditTypeStatistics> =
+        currentWeekEditTypeStatisticsDao.getAll()
+
+    override fun getCurrentWeekCountryStatistics(): List<CountryStatistics> =
+        currentWeekCountryStatisticsDao.getAll()
+
+    override fun getCurrentWeekCountryStatisticsOfCountryWithBiggestSolvedCount(): CountryStatistics? =
+        currentWeekCountryStatisticsDao.getCountryWithBiggestSolvedCount()
+
     fun addOne(type: String, position: LatLon) {
         editTypeStatisticsDao.addOne(type)
-        getRealCountryCode(position)?.let { countryStatisticsDao.addOne(it) }
+        currentWeekEditTypeStatisticsDao.addOne(type)
+        getRealCountryCode(position)?.let {
+            countryStatisticsDao.addOne(it)
+            currentWeekCountryStatisticsDao.addOne(it)
+        }
         listeners.forEach { it.onAddedOne(type) }
         updateDaysActive()
     }

     fun subtractOne(type: String, position: LatLon) {
         editTypeStatisticsDao.subtractOne(type)
-        getRealCountryCode(position)?.let { countryStatisticsDao.subtractOne(it) }
+        currentWeekEditTypeStatisticsDao.subtractOne(type)
+        getRealCountryCode(position)?.let {
+            countryStatisticsDao.subtractOne(it)
+            currentWeekCountryStatisticsDao.subtractOne(it)
+        }
         listeners.forEach { it.onSubtractedOne(type) }
         updateDaysActive()
     }
@@ -109,6 +137,9 @@ class StatisticsController(

         editTypeStatisticsDao.replaceAll(statistics.types.associate { it.type to it.count })
         countryStatisticsDao.replaceAll(statistics.countries)
+        currentWeekEditTypeStatisticsDao.replaceAll(statistics.currentWeekTypes.associate { it.type to it.count })
+        currentWeekCountryStatisticsDao.replaceAll(statistics.currentWeekCountries)
+        currentWeekRank = statistics.currentWeekRank
         rank = statistics.rank
         daysActive = statistics.daysActive
         lastUpdate = statistics.lastUpdate
@@ -119,10 +150,13 @@ class StatisticsController(
     private fun clear() {
         editTypeStatisticsDao.clear()
         countryStatisticsDao.clear()
+        currentWeekEditTypeStatisticsDao.clear()
+        currentWeekCountryStatisticsDao.clear()
         prefs.edit(true) {
             remove(Prefs.USER_DAYS_ACTIVE)
             remove(Prefs.IS_SYNCHRONIZING_STATISTICS)
             remove(Prefs.USER_GLOBAL_RANK)
+            remove(Prefs.USER_GLOBAL_RANK_CURRENT_WEEK)
             remove(Prefs.USER_LAST_TIMESTAMP_ACTIVE)
         }

diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsModule.kt
index a3d2fabc4c5..622dbbaa316 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsModule.kt
@@ -5,11 +5,24 @@ import org.koin.dsl.module

 private const val STATISTICS_BACKEND_URL = "https://www.westnordost.de/streetcomplete/statistics/"
 val statisticsModule = module {
-    factory { CountryStatisticsDao(get()) }
-    factory { EditTypeStatisticsDao(get()) }
+
+    factory(named("EditTypeStatistics")) { EditTypeStatisticsDao(get(), EditTypeStatisticsTables.NAME) }
+    factory(named("CountryStatistics")) { CountryStatisticsDao(get(), CountryStatisticsTables.NAME) }
+
+    factory(named("EditTypeStatisticsCurrentWeek")) { EditTypeStatisticsDao(get(), EditTypeStatisticsTables.NAME_CURRENT_WEEK) }
+    factory(named("CountryStatisticsCurrentWeek")) { CountryStatisticsDao(get(), CountryStatisticsTables.NAME_CURRENT_WEEK) }
+
     factory { StatisticsDownloader(STATISTICS_BACKEND_URL, get()) }
     factory { StatisticsParser(get(named("TypeAliases"))) }

     single<StatisticsSource> { get<StatisticsController>() }
-    single { StatisticsController(get(), get(), get(named("CountryBoundariesFuture")), get(), get()) }
+    single { StatisticsController(
+        editTypeStatisticsDao = get(named("EditTypeStatistics")),
+        countryStatisticsDao = get(named("CountryStatistics")),
+        currentWeekEditTypeStatisticsDao = get(named("EditTypeStatisticsCurrentWeek")),
+        currentWeekCountryStatisticsDao = get(named("CountryStatisticsCurrentWeek")),
+        countryBoundaries = get(named("CountryBoundariesFuture")),
+        prefs = get(),
+        userLoginStatusSource = get()
+    ) }
 }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParser.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParser.kt
index 406238cc70a..99a46d349f7 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParser.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParser.kt
@@ -6,29 +6,56 @@ import org.json.JSONObject
 class StatisticsParser(private val typeAliases: List<Pair<String, String>>) {
     fun parse(json: String): Statistics {
         val obj = JSONObject(json)
-        val questTypesJson = obj.getJSONObject("questTypes")
+
+        val typesStatistics = parseEditTypeStatistics(obj.getJSONObject("questTypes"))
+        val countriesStatistics = parseCountriesStatistics(
+            obj.getJSONObject("countries"),
+            obj.getJSONObject("countryRanks")
+        )
+        val rank = obj.getInt("rank")
+        val daysActive = obj.getInt("daysActive")
+        val isAnalyzing = obj.getBoolean("isAnalyzing")
+        val lastUpdate = Instant.parse(obj.getString("lastUpdate"))
+
+        val currentWeekRank = obj.getInt("currentWeekRank")
+        val currentWeekTypesStatistics = parseEditTypeStatistics(obj.getJSONObject("currentWeekQuestTypes"))
+        val currentWeekCountriesStatistics = parseCountriesStatistics(
+            obj.getJSONObject("currentWeekCountries"),
+            obj.getJSONObject("currentWeekCountryRanks")
+        )
+
+        return Statistics(
+            typesStatistics,
+            countriesStatistics,
+            rank,
+            daysActive,
+            currentWeekRank,
+            currentWeekTypesStatistics,
+            currentWeekCountriesStatistics,
+            lastUpdate.toEpochMilliseconds(),
+            isAnalyzing,
+        )
+    }
+
+    private fun parseEditTypeStatistics(obj: JSONObject): List<EditTypeStatistics> {
         val typesByName: MutableMap<String, Int> = mutableMapOf()
-        for (questTypeName in questTypesJson.keys()) {
-            typesByName[questTypeName] = questTypesJson.getInt(questTypeName)
+        for (questTypeName in obj.keys()) {
+            typesByName[questTypeName] = obj.getInt(questTypeName)
         }
         mergeTypeAliases(typesByName)
-        val typesStatistics = typesByName.map { EditTypeStatistics(it.key, it.value) }
-        val countriesJson = obj.getJSONObject("countries")
+        return typesByName.map { EditTypeStatistics(it.key, it.value) }
+    }
+
+    private fun parseCountriesStatistics(editsObj: JSONObject, ranksObj: JSONObject): List<CountryStatistics> {
         val countries: MutableMap<String, Int> = mutableMapOf()
-        for (country in countriesJson.keys()) {
-            countries[country] = countriesJson.getInt(country)
+        for (country in editsObj.keys()) {
+            countries[country] = editsObj.getInt(country)
         }
-        val countryRanksJson = obj.getJSONObject("countryRanks")
-        val countryRanks: MutableMap<String, Int> = mutableMapOf()
-        for (country in countryRanksJson.keys()) {
-            countryRanks[country] = countryRanksJson.getInt(country)
+        val ranks: MutableMap<String, Int> = mutableMapOf()
+        for (country in ranksObj.keys()) {
+            ranks[country] = ranksObj.getInt(country)
         }
-        val countriesStatistics = countries.map { CountryStatistics(it.key, it.value, countryRanks[it.key]) }
-        val rank = obj.getInt("rank")
-        val daysActive = obj.getInt("daysActive")
-        val isAnalyzing = obj.getBoolean("isAnalyzing")
-        val lastUpdate = Instant.parse(obj.getString("lastUpdate"))
-        return Statistics(typesStatistics, countriesStatistics, rank, daysActive, lastUpdate.toEpochMilliseconds(), isAnalyzing)
+        return countries.map { CountryStatistics(it.key, it.value, ranks[it.key]) }
     }

     private fun mergeTypeAliases(map: MutableMap<String, Int>) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsSource.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsSource.kt
index d8a50e589b0..ea892f85dc3 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsSource.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsSource.kt
@@ -23,7 +23,10 @@ interface StatisticsSource {
     /** Whether the statistics are still being synchronized with the backend */
     val isSynchronizing: Boolean

-    /** Return the total amount of quests solved*/
+    /** Users' global rank in the last 7 days. If <= 0, it's not set yet */
+    val currentWeekRank: Int
+
+    /** Return the total amount of quests solved */
     fun getEditCount(): Int

     /** Return amount of edits of the given type done */
@@ -38,9 +41,22 @@ interface StatisticsSource {
     /** Return all country statistics */
     fun getCountryStatistics(): List<CountryStatistics>

+    /** Return the total amount of quests solved in the last 7 days*/
+    fun getCurrentWeekEditCount(): Int
+
     /** Return the country statistics of the country in which the user solved the most quests, if any */
     fun getCountryStatisticsOfCountryWithBiggestSolvedCount(): CountryStatistics?

+    /** Return all edit type statistics of the last 7 days */
+    fun getCurrentWeekEditTypeStatistics(): List<EditTypeStatistics>
+
+    /** Return all country statistics of the last 7 days */
+    fun getCurrentWeekCountryStatistics(): List<CountryStatistics>
+
+    /** Return the country statistics of the country in which the user solved the most quests of the last 7 days, if any */
+    fun getCurrentWeekCountryStatisticsOfCountryWithBiggestSolvedCount(): CountryStatistics?
+
     fun addListener(listener: Listener)
     fun removeListener(listener: Listener)
+
 }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt
index 1c6902c33b1..5ad9304e598 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt
@@ -231,7 +231,6 @@ class MainFragment :
         binding.zoomInButton.setOnClickListener { onClickZoomIn() }
         binding.zoomOutButton.setOnClickListener { onClickZoomOut() }
         binding.createButton.setOnClickListener { onClickCreateButton() }
-        binding.answersCounterFragment.setOnClickListener { starInfoMenu() }

         updateOffsetWithOpenBottomSheet()
     }
@@ -741,10 +740,6 @@ class MainFragment :
         if (follow) mapFragment.centerCurrentPositionIfFollowing()
     }

-    fun starInfoMenu() {
-        val intent = Intent(requireContext(), UserActivity::class.java)
-        startActivity(intent)
-    }
     /* -------------------------------------- Context Menu -------------------------------------- */

     private fun showMapContextMenu(position: LatLon) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/AnswersCounterFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/AnswersCounterFragment.kt
index 538025c2ef0..fd597104a38 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/AnswersCounterFragment.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/AnswersCounterFragment.kt
@@ -1,6 +1,8 @@
 package de.westnordost.streetcomplete.screens.main.controls

 import android.content.SharedPreferences
+import android.os.Bundle
+import android.view.View
 import androidx.fragment.app.Fragment
 import de.westnordost.streetcomplete.Prefs
 import de.westnordost.streetcomplete.R
@@ -11,7 +13,9 @@ import de.westnordost.streetcomplete.data.upload.UploadProgressListener
 import de.westnordost.streetcomplete.data.upload.UploadProgressSource
 import de.westnordost.streetcomplete.data.user.statistics.StatisticsSource
 import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import org.koin.android.ext.android.inject

 /** Fragment that shows the "star" with the number of solved quests */
@@ -25,6 +29,7 @@ class AnswersCounterFragment : Fragment(R.layout.fragment_answers_counter) {
     private val unsyncedChangesCountSource: UnsyncedChangesCountSource by inject()

     private val answersCounterView get() = view as AnswersCounterView
+    private var showCurrentWeek: Boolean = false

     private val uploadProgressListener = object : UploadProgressListener {
         override fun onStarted() { viewLifecycleScope.launch { updateProgress() } }
@@ -60,6 +65,21 @@ class AnswersCounterFragment : Fragment(R.layout.fragment_answers_counter) {

     /* --------------------------------------- Lifecycle ---------------------------------------- */

+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        showCurrentWeek = savedInstanceState?.getBoolean(SHOW_CURRENT_WEEK, false) ?: false
+        answersCounterView.showLabel = showCurrentWeek
+
+        answersCounterView.setOnClickListener {
+            showCurrentWeek = !showCurrentWeek
+            viewLifecycleScope.launch {
+                updateCount(false)
+                answersCounterView.showLabel = showCurrentWeek
+            }
+        }
+    }
+
     override fun onStart() {
         super.onStart()

@@ -81,6 +101,11 @@ class AnswersCounterFragment : Fragment(R.layout.fragment_answers_counter) {
         unsyncedChangesCountSource.removeListener(unsyncedChangesCountListener)
     }

+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        outState.putBoolean(SHOW_CURRENT_WEEK, showCurrentWeek)
+    }
+
     private val isAutosync: Boolean get() =
         Prefs.Autosync.valueOf(prefs.getString(Prefs.AUTOSYNC, "ON")!!) == Prefs.Autosync.ON

@@ -92,11 +117,18 @@ class AnswersCounterFragment : Fragment(R.layout.fragment_answers_counter) {
     private suspend fun updateCount(animated: Boolean) {
         /* if autosync is on, show the uploaded count + the to-be-uploaded count (but only those
            uploadables that will be part of the statistics, so no note stuff) */
-        val amount = statisticsSource.getEditCount() + if (isAutosync) unsyncedChangesCountSource.getSolvedCount() else 0
+        val editCount = withContext(Dispatchers.IO) {
+            if (showCurrentWeek) statisticsSource.getCurrentWeekEditCount() else statisticsSource.getEditCount()
+        }
+        val amount = editCount + if (isAutosync) unsyncedChangesCountSource.getSolvedCount() else 0
         answersCounterView.setUploadedCount(amount, animated)
     }

     private fun addCount(diff: Int, animate: Boolean) {
         answersCounterView.setUploadedCount(answersCounterView.uploadedCount + diff, animate)
     }
+
+    companion object {
+        private const val SHOW_CURRENT_WEEK = "showCurrentWeek"
+    }
 }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/AnswersCounterView.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/AnswersCounterView.kt
index cbcefd027cf..7c7f5a8f0dd 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/AnswersCounterView.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/AnswersCounterView.kt
@@ -6,6 +6,7 @@ import android.view.LayoutInflater
 import android.view.animation.AccelerateDecelerateInterpolator
 import android.view.animation.DecelerateInterpolator
 import android.widget.RelativeLayout
+import androidx.core.view.isGone
 import androidx.core.view.isInvisible
 import de.westnordost.streetcomplete.databinding.ViewAnswersCounterBinding

@@ -30,6 +31,10 @@ class AnswersCounterView @JvmOverloads constructor(
             binding.progressView.isInvisible = !value
         }

+    var showLabel: Boolean
+        set(value) { binding.labelView.isGone = !value }
+        get() = binding.labelView.isGone
+
     fun setUploadedCount(uploadedCount: Int, animate: Boolean) {
         if (this.uploadedCount < uploadedCount && animate) {
             animateChange()
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileFragment.kt
index 9afc41290b1..2ac774598b9 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileFragment.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileFragment.kt
@@ -6,6 +6,7 @@ import android.graphics.Bitmap
 import android.graphics.BitmapFactory
 import android.os.Bundle
 import android.view.View
+import android.widget.TextView
 import androidx.core.net.toUri
 import androidx.core.view.isGone
 import androidx.fragment.app.Fragment
@@ -14,7 +15,9 @@ import de.westnordost.streetcomplete.data.UnsyncedChangesCountSource
 import de.westnordost.streetcomplete.data.user.UserDataSource
 import de.westnordost.streetcomplete.data.user.UserLoginStatusController
 import de.westnordost.streetcomplete.data.user.UserUpdater
+import de.westnordost.streetcomplete.data.user.achievements.Achievement
 import de.westnordost.streetcomplete.data.user.achievements.AchievementsSource
+import de.westnordost.streetcomplete.data.user.statistics.CountryStatistics
 import de.westnordost.streetcomplete.data.user.statistics.StatisticsSource
 import de.westnordost.streetcomplete.databinding.FragmentProfileBinding
 import de.westnordost.streetcomplete.util.ktx.createBitmap
@@ -53,10 +56,10 @@ class ProfileFragment : Fragment(R.layout.fragment_profile) {
     }
     private val statisticsListener = object : StatisticsSource.Listener {
         override fun onAddedOne(type: String) {
-            viewLifecycleScope.launch { updateEditCountText() }
+            viewLifecycleScope.launch { updateEditCountTexts() }
         }
         override fun onSubtractedOne(type: String) {
-            viewLifecycleScope.launch { updateEditCountText() }
+            viewLifecycleScope.launch { updateEditCountTexts()  }
         }
         override fun onUpdatedAll() {
             viewLifecycleScope.launch { updateStatisticsTexts() }
@@ -68,6 +71,15 @@ class ProfileFragment : Fragment(R.layout.fragment_profile) {
             viewLifecycleScope.launch { updateDaysActiveText() }
         }
     }
+    private val achievementsListener = object : AchievementsSource.Listener {
+        override fun onAchievementUnlocked(achievement: Achievement, level: Int) {
+            viewLifecycleScope.launch { updateAchievementLevelsText() }
+        }
+
+        override fun onAllAchievementsUpdated() {
+            viewLifecycleScope.launch { updateAchievementLevelsText() }
+        }
+    }
     private val userListener = object : UserDataSource.Listener {
         override fun onUpdated() { viewLifecycleScope.launch { updateUserName() } }
     }
@@ -99,14 +111,15 @@ class ProfileFragment : Fragment(R.layout.fragment_profile) {
             userUpdater.addUserAvatarListener(userAvatarListener)
             statisticsSource.addListener(statisticsListener)
             unsyncedChangesCountSource.addListener(unsyncedChangesCountListener)
+            achievementsSource.addListener(achievementsListener)

             updateUserName()
             updateAvatar()
-            updateEditCountText()
+            updateEditCountTexts()
             updateUnpublishedEditsText()
             updateDaysActiveText()
-            updateGlobalRankText()
-            updateLocalRankText()
+            updateGlobalRankTexts()
+            updateLocalRankTexts()
             updateAchievementLevelsText()
         }
     }
@@ -117,6 +130,7 @@ class ProfileFragment : Fragment(R.layout.fragment_profile) {
         statisticsSource.removeListener(statisticsListener)
         userDataSource.removeListener(userListener)
         userUpdater.removeUserAvatarListener(userAvatarListener)
+        achievementsSource.removeListener(achievementsListener)
     }

     private fun updateUserName() {
@@ -130,14 +144,15 @@ class ProfileFragment : Fragment(R.layout.fragment_profile) {
     }

     private suspend fun updateStatisticsTexts() {
-        updateEditCountText()
+        updateEditCountTexts()
         updateDaysActiveText()
-        updateGlobalRankText()
-        updateLocalRankText()
+        updateGlobalRankTexts()
+        updateLocalRankTexts()
     }

-    private suspend fun updateEditCountText() {
+    private suspend fun updateEditCountTexts() {
         binding.editCountText.text = withContext(Dispatchers.IO) { statisticsSource.getEditCount().toString() }
+        binding.currentWeekEditCountText.text = withContext(Dispatchers.IO) { statisticsSource.getCurrentWeekEditCount().toString() }
     }

     private suspend fun updateUnpublishedEditsText() {
@@ -153,37 +168,62 @@ class ProfileFragment : Fragment(R.layout.fragment_profile) {
         binding.daysActiveText.background = LaurelWreathDrawable(resources, min(daysActive + 20, 100))
     }

-    private fun updateGlobalRankText() {
+    private fun updateGlobalRankTexts() {
+        updateGlobalRankText(
+            statisticsSource.rank,
+            binding.globalRankContainer,
+            binding.globalRankText
+        )
+
+        updateGlobalRankText(
+            statisticsSource.currentWeekRank,
+            binding.currentWeekGlobalRankContainer,
+            binding.currentWeekGlobalRankText
+        )
+    }
+
+    private fun updateGlobalRankText(rank: Int, container: View, circle: TextView ) {
+        container.isGone = rank <= 0 || statisticsSource.getEditCount() <= 100
+        circle.text = "#$rank"
+        circle.background = LaurelWreathDrawable(resources, getScaledGlobalRank(rank))
+    }
+
+    /** Translate the user's actual rank to a value from 0 (bad) to 100 (good) */
+    private fun getScaledGlobalRank(rank: Int): Int {
         // note that global rank merges multiple people with the same score
         // in case that 1000 people made 11 edits all will have the same rank (say, 3814)
         // in case that 1000 people made 10 edits all will have the same rank (in this case - 3815)
-        val rank = statisticsSource.rank
-        binding.globalRankContainer.isGone = rank <= 0 || statisticsSource.getEditCount() <= 100
-        binding.globalRankText.text = "#$rank"
         val rankEnoughForFullMarks = 1000
         val rankEnoughToStartGrowingReward = 3800
         val ranksAboveThreshold = max(rankEnoughToStartGrowingReward - rank, 0)
-        val scaledRank = (ranksAboveThreshold * 100.0 / (rankEnoughToStartGrowingReward - rankEnoughForFullMarks)).toInt()
-        binding.globalRankText.background = LaurelWreathDrawable(resources, min(scaledRank, 100))
-    }
-
-    private suspend fun updateLocalRankText() {
-        val statistics = withContext(Dispatchers.IO) {
-            statisticsSource.getCountryStatisticsOfCountryWithBiggestSolvedCount()
-        }
-        if (statistics == null) {
-            binding.localRankContainer.isGone = true
-        }
-        else {
-            val shouldShow = statistics.rank != null && statistics.rank > 0 && statistics.count > 50
-            val countryLocale = Locale("", statistics.countryCode)
-            binding.localRankContainer.isGone = !shouldShow
-            if (shouldShow) {
-                binding.localRankText.text = "#${statistics.rank}"
-                binding.localRankLabel.text = getString(R.string.user_profile_local_rank, countryLocale.displayCountry)
-                binding.localRankText.background = LaurelWreathDrawable(resources, min(100 - statistics.rank!!, 100))
-            }
-        }
+        return min(100, (ranksAboveThreshold * 100.0 / (rankEnoughToStartGrowingReward - rankEnoughForFullMarks)).toInt())
+    }
+
+    private suspend fun updateLocalRankTexts() {
+        updateLocalRankText(
+            withContext(Dispatchers.IO) { statisticsSource.getCountryStatisticsOfCountryWithBiggestSolvedCount() },
+            50,
+            binding.localRankContainer,
+            binding.localRankLabel,
+            binding.localRankText
+        )
+
+        updateLocalRankText(
+            withContext(Dispatchers.IO) { statisticsSource.getCurrentWeekCountryStatisticsOfCountryWithBiggestSolvedCount() },
+            5,
+            binding.currentWeekLocalRankContainer,
+            binding.currentWeekLocalRankLabel,
+            binding.currentWeekLocalRankText
+        )
+    }
+
+    private fun updateLocalRankText(statistics: CountryStatistics?, min: Int, container: View, label: TextView, circle: TextView) {
+        val rank = statistics?.rank ?: 0
+        container.isGone = statistics == null || rank <= 0 || statistics.count <= min
+        circle.text = "#$rank"
+        val countryLocale = Locale("", statistics?.countryCode ?: "")
+        label.text = getString(R.string.user_profile_local_rank, countryLocale.displayCountry)
+        circle.background = LaurelWreathDrawable(resources, min(100 - rank, 100))
     }

     private suspend fun updateAchievementLevelsText() {
diff --git a/app/src/main/res/drawable/space_24dp.xml b/app/src/main/res/drawable/space_24dp.xml
new file mode 100644
index 00000000000..261afd38d13
--- /dev/null
+++ b/app/src/main/res/drawable/space_24dp.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+    <size android:height="24dp" android:width="24dp"/>
+</shape>
diff --git a/app/src/main/res/layout/fragment_profile.xml b/app/src/main/res/layout/fragment_profile.xml
index dac8aff5624..677d9446b54 100644
--- a/app/src/main/res/layout/fragment_profile.xml
+++ b/app/src/main/res/layout/fragment_profile.xml
@@ -4,10 +4,6 @@
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     xmlns:tools="http://schemas.android.com/tools"
-    android:paddingTop="@dimen/activity_vertical_margin"
-    android:paddingBottom="@dimen/activity_vertical_margin"
-    android:paddingStart="@dimen/activity_horizontal_margin"
-    android:paddingEnd="@dimen/activity_horizontal_margin"
     xmlns:app="http://schemas.android.com/apk/res-auto">

     <androidx.constraintlayout.widget.ConstraintLayout
@@ -26,7 +22,13 @@
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:orientation="vertical"
-                android:paddingBottom="48dp">
+                android:layout_marginTop="@dimen/activity_vertical_margin"
+                android:layout_marginBottom="@dimen/activity_vertical_margin"
+                android:layout_marginStart="@dimen/activity_horizontal_margin"
+                android:layout_marginEnd="@dimen/activity_horizontal_margin"
+                android:divider="@drawable/space_24dp"
+                android:paddingBottom="64dp"
+                android:showDividers="middle">

                 <RelativeLayout
                     android:id="@+id/profileContainer"
@@ -104,15 +106,40 @@

                 </RelativeLayout>

+                <RelativeLayout
+                    android:id="@+id/buttonsContainer"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content">
+
+                    <Button
+                        android:id="@+id/profileButton"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_alignParentStart="true"
+                        app:icon="@drawable/ic_open_in_browser_24dp"
+                        android:layout_centerHorizontal="true"
+                        android:text="@string/osm_profile" />
+
+                    <Button
+                        android:id="@+id/logoutButton"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_alignParentEnd="true"
+                        android:text="@string/user_logout"
+                        style="@style/Widget.MaterialComponents.Button.OutlinedButton"/>
+
+                </RelativeLayout>
+
+                <View
+                    android:layout_width="match_parent"
+                    android:layout_height="1dp"
+                    android:background="@color/divider"/>
+
                 <com.google.android.flexbox.FlexboxLayout
                     android:id="@+id/badgesContainer"
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
                     android:orientation="horizontal"
-                    android:layout_marginTop="16dp"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@id/profileContainer"
                     app:flexWrap="wrap"
                     app:showDivider="middle"
                     app:dividerDrawableHorizontal="@drawable/space_16dp"
@@ -261,36 +288,128 @@

                 </com.google.android.flexbox.FlexboxLayout>

-            </LinearLayout>
+                <LinearLayout
+                    android:id="@+id/currentWeekSolvedQuestsContainer"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:divider="@drawable/space_4dp"
+                    android:gravity="center_vertical"
+                    android:orientation="horizontal"
+                    android:showDividers="middle">

-        </ScrollView>
+                    <TextView
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:gravity="center_vertical"
+                        android:textAlignment="gravity"
+                        android:textAppearance="@style/TextAppearance.Title"
+                        android:text="@string/user_profile_current_week_title" />

-        <RelativeLayout
-            android:id="@+id/buttonsContainer"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintBottom_toBottomOf="parent">
-
-            <Button
-                android:id="@+id/profileButton"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_alignParentStart="true"
-                app:icon="@drawable/ic_open_in_browser_24dp"
-                android:layout_centerHorizontal="true"
-                android:text="@string/osm_profile" />
-
-            <Button
-                android:id="@+id/logoutButton"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_alignParentEnd="true"
-                android:text="@string/user_logout"
-                style="@style/Widget.MaterialComponents.Button"/>
+                    <ImageView
+                        android:layout_width="24dp"
+                        android:layout_height="24dp"
+                        android:src="@drawable/ic_star_48dp" />
+
+                    <TextView
+                        android:id="@+id/currentWeekEditCountText"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:gravity="center_vertical"
+                        android:maxLines="1"
+                        android:textAlignment="gravity"
+                        android:textAppearance="@style/TextAppearance.Title"
+                        tools:text="1021" />
+
+                </LinearLayout>
+
+                <com.google.android.flexbox.FlexboxLayout
+                    android:id="@+id/currentWeekBadgesContainer"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:orientation="horizontal"
+                    app:flexWrap="wrap"
+                    app:showDivider="middle"
+                    app:dividerDrawableHorizontal="@drawable/space_16dp"
+                    app:dividerDrawableVertical="@drawable/space_8dp"
+                    app:showDividerVertical="middle"
+                    app:showDividerHorizontal="middle"
+                    app:alignContent="stretch">
+
+                    <RelativeLayout
+                        android:id="@+id/currentWeekLocalRankContainer"
+                        android:layout_width="84dp"
+                        android:layout_height="wrap_content">
+
+                        <TextView
+                            android:id="@+id/currentWeekLocalRankText"
+                            android:layout_width="84dp"
+                            android:layout_height="84dp"
+                            android:background="@drawable/background_inverted_text_circle"
+                            android:gravity="center"
+                            android:padding="8dp"
+                            android:maxLines="1"
+                            android:textAppearance="@style/TextAppearance.Title"
+                            android:layout_centerHorizontal="true"
+                            app:autoSizeTextType="uniform"
+                            app:autoSizeMaxTextSize="22dp"
+                            app:autoSizeMinTextSize="12dp"
+                            android:textColor="@color/background"
+                            tools:text="#1"/>
+
+                        <TextView
+                            android:id="@+id/currentWeekLocalRankLabel"
+                            android:layout_width="match_parent"
+                            android:layout_height="wrap_content"
+                            android:layout_centerHorizontal="true"
+                            android:gravity="center"
+                            android:textAppearance="@style/TextAppearance.Body"
+                            android:labelFor="@id/currentWeekLocalRankText"
+                            android:layout_below="@+id/currentWeekLocalRankText"
+                            android:layout_marginTop="4dp"
+                            android:text="@string/user_profile_local_rank"
+                            tools:text="Rank in\nUnited States of America"/>
+
+                    </RelativeLayout>
+
+                    <RelativeLayout
+                        android:id="@+id/currentWeekGlobalRankContainer"
+                        android:layout_width="84dp"
+                        android:layout_height="wrap_content">
+
+                        <TextView
+                            android:id="@+id/currentWeekGlobalRankText"
+                            android:layout_width="84dp"
+                            android:layout_height="84dp"
+                            android:background="@drawable/background_inverted_text_circle"
+                            android:gravity="center"
+                            android:padding="8dp"
+                            android:maxLines="1"
+                            android:textAppearance="@style/TextAppearance.Title"
+                            android:layout_centerHorizontal="true"
+                            app:autoSizeTextType="uniform"
+                            app:autoSizeMaxTextSize="22dp"
+                            app:autoSizeMinTextSize="12dp"
+                            android:textColor="@color/background"
+                            tools:text="#123456"/>
+
+                        <TextView
+                            android:layout_width="match_parent"
+                            android:layout_height="wrap_content"
+                            android:layout_centerHorizontal="true"
+                            android:gravity="center"
+                            android:textAppearance="@style/TextAppearance.Body"
+                            android:labelFor="@id/currentWeekGlobalRankText"
+                            android:layout_below="@id/currentWeekGlobalRankText"
+                            android:layout_marginTop="4dp"
+                            android:text="@string/user_profile_global_rank"/>

-        </RelativeLayout>
+                    </RelativeLayout>
+
+                </com.google.android.flexbox.FlexboxLayout>
+
+            </LinearLayout>
+
+        </ScrollView>

     </androidx.constraintlayout.widget.ConstraintLayout>

diff --git a/app/src/main/res/layout/view_answers_counter.xml b/app/src/main/res/layout/view_answers_counter.xml
index 18ba6ecbc14..d42dc61f68d 100644
--- a/app/src/main/res/layout/view_answers_counter.xml
+++ b/app/src/main/res/layout/view_answers_counter.xml
@@ -5,8 +5,7 @@
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
     android:clipChildren="false"
-    android:clipToPadding="false"
-    tools:background="#666">
+    android:clipToPadding="false">

     <ProgressBar
         android:id="@+id/progressView"
@@ -30,25 +29,45 @@
         android:src="@drawable/ic_star_white_shadow_32dp"
         tools:ignore="RtlHardcoded" />

-    <TextView
-        android:id="@+id/textView"
+    <RelativeLayout
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:gravity="center"
         android:layout_toRightOf="@id/iconView"
+        android:layout_marginLeft="-12dp"
         android:layout_centerVertical="true"
-        android:textAppearance="@style/TextAppearance.BigBody"
-        android:textStyle="bold"
-        android:textColor="#fff"
-        android:paddingLeft="16dp"
-        android:paddingRight="16dp"
-        android:layout_marginLeft="-8dp"
-        tools:scaleX="1.6"
-        tools:scaleY="1.6"
-        android:shadowColor="#000"
-        android:shadowRadius="8"
-        android:shadowDy="2"
-        tools:text="1583"
-        tools:ignore="RtlHardcoded"/>
+        tools:ignore="RtlHardcoded">
+
+        <TextView
+            android:id="@+id/labelView"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="@string/user_profile_current_week_title"
+            android:paddingLeft="16dp"
+            android:paddingRight="16dp"
+            android:textColor="#fff"
+            android:shadowColor="#000"
+            android:shadowRadius="6"
+            android:shadowDy="2"
+            android:textStyle="bold"
+            tools:visibility="visible"
+            android:visibility="gone"/>
+
+        <TextView
+            android:id="@+id/textView"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:gravity="center"
+            android:layout_below="@+id/labelView"
+            android:textAppearance="@style/TextAppearance.BigBody"
+            android:textStyle="bold"
+            android:textColor="#fff"
+            android:paddingLeft="16dp"
+            android:paddingRight="16dp"
+            android:shadowColor="#000"
+            android:shadowRadius="6"
+            android:shadowDy="2"
+            tools:text="1583"/>
+
+    </RelativeLayout>

 </merge>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7f636283b95..9e5ee1dcdf6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -357,6 +357,7 @@ Before uploading your changes, the app checks with a <a href=\"https://www.we
     <string name="user_profile_local_rank">Rank in\n%s</string>
     <string name="user_profile_days_active">Days\nactive</string>
     <string name="user_profile_achievement_levels">Achievement\nlevels</string>
+    <string name="user_profile_current_week_title">This week</string>

     <string name="user_login">Login</string>
     <string name="user_logout">Logout</string>

diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsDaoTest.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsDaoTest.kt
index 67b506b978f..5d6de8c1833 100644
--- a/app/src/androidTest/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsDaoTest.kt
+++ b/app/src/androidTest/java/de/westnordost/streetcomplete/data/user/statistics/CountryStatisticsDaoTest.kt
@@ -9,7 +9,7 @@ class CountryStatisticsDaoTest : ApplicationDbTestCase() {
     private lateinit var dao: CountryStatisticsDao

     @Before fun createDao() {
-        dao = CountryStatisticsDao(database)
+        dao = CountryStatisticsDao(database, CountryStatisticsTables.NAME)
     }

     @Test fun addAndSubtract() {
diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsDaoTest.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsDaoTest.kt
index e4167e4d454..2a290377d0c 100644
--- a/app/src/androidTest/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsDaoTest.kt
+++ b/app/src/androidTest/java/de/westnordost/streetcomplete/data/user/statistics/EditTypeStatisticsDaoTest.kt
@@ -9,7 +9,7 @@ class EditTypeStatisticsDaoTest : ApplicationDbTestCase() {
     private lateinit var daoType: EditTypeStatisticsDao

     @Before fun createDao() {
-        daoType = EditTypeStatisticsDao(database)
+        daoType = EditTypeStatisticsDao(database, EditTypeStatisticsTables.NAME)
     }

     @Test fun getZero() {
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsControllerTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsControllerTest.kt
index f56c78ce431..1974b24f974 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsControllerTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsControllerTest.kt
@@ -19,6 +19,8 @@ class StatisticsControllerTest {

     private lateinit var editTypeStatisticsDao: EditTypeStatisticsDao
     private lateinit var countryStatisticsDao: CountryStatisticsDao
+    private lateinit var currentWeekEditTypeStatisticsDao: EditTypeStatisticsDao
+    private lateinit var currentWeekCountryStatisticsDao: CountryStatisticsDao
     private lateinit var countryBoundaries: CountryBoundaries
     private lateinit var prefs: SharedPreferences
     private lateinit var loginStatusSource: UserLoginStatusSource
@@ -33,6 +35,8 @@ class StatisticsControllerTest {
     @Before fun setUp() {
         editTypeStatisticsDao = mock()
         countryStatisticsDao = mock()
+        currentWeekEditTypeStatisticsDao = mock()
+        currentWeekCountryStatisticsDao = mock()
         countryBoundaries = mock()
         prefs = mock()
         on(prefs.edit()).thenReturn(mock())
@@ -48,7 +52,9 @@ class StatisticsControllerTest {
         }

         statisticsController = StatisticsController(
-            editTypeStatisticsDao, countryStatisticsDao, ft, prefs, loginStatusSource
+            editTypeStatisticsDao, countryStatisticsDao,
+            currentWeekEditTypeStatisticsDao, currentWeekCountryStatisticsDao,
+            ft, prefs, loginStatusSource
         )
         statisticsController.addListener(listener)
     }
@@ -67,6 +73,8 @@ class StatisticsControllerTest {

         verify(editTypeStatisticsDao).addOne("TestQuestTypeA")
         verify(countryStatisticsDao).addOne("US")
+        verify(currentWeekEditTypeStatisticsDao).addOne("TestQuestTypeA")
+        verify(currentWeekCountryStatisticsDao).addOne("US")
         verify(listener).onAddedOne(questA)
     }

@@ -76,6 +84,7 @@ class StatisticsControllerTest {
         statisticsController.addOne(questA, p(0.0, 0.0))

         verify(editTypeStatisticsDao).addOne("TestQuestTypeA")
+        verify(currentWeekEditTypeStatisticsDao).addOne("TestQuestTypeA")
         verify(listener).onAddedOne(questA)
         verify(listener).onUpdatedDaysActive()
     }
@@ -86,6 +95,8 @@ class StatisticsControllerTest {

         verify(editTypeStatisticsDao).subtractOne("TestQuestTypeA")
         verify(countryStatisticsDao).subtractOne("US")
+        verify(currentWeekEditTypeStatisticsDao).subtractOne("TestQuestTypeA")
+        verify(currentWeekCountryStatisticsDao).subtractOne("US")
         verify(listener).onSubtractedOne(questA)
     }

@@ -95,6 +106,7 @@ class StatisticsControllerTest {
         statisticsController.subtractOne(questA, p(0.0, 0.0))

         verify(editTypeStatisticsDao).subtractOne("TestQuestTypeA")
+        verify(currentWeekEditTypeStatisticsDao).subtractOne("TestQuestTypeA")
         verify(listener).onSubtractedOne(questA)
         verify(listener).onUpdatedDaysActive()
     }
@@ -107,9 +119,12 @@ class StatisticsControllerTest {

         verify(editTypeStatisticsDao).clear()
         verify(countryStatisticsDao).clear()
+        verify(currentWeekCountryStatisticsDao).clear()
+        verify(currentWeekEditTypeStatisticsDao).clear()
         verify(editor).remove(Prefs.USER_DAYS_ACTIVE)
         verify(editor).remove(Prefs.IS_SYNCHRONIZING_STATISTICS)
         verify(editor).remove(Prefs.USER_GLOBAL_RANK)
+        verify(editor).remove(Prefs.USER_GLOBAL_RANK_CURRENT_WEEK)
         verify(editor).remove(Prefs.USER_LAST_TIMESTAMP_ACTIVE)
         verify(listener).onCleared()
     }
@@ -129,6 +144,15 @@ class StatisticsControllerTest {
             ),
             rank = 999,
             daysActive = 333,
+            currentWeekRank = 111,
+            currentWeekTypes = listOf(
+                EditTypeStatistics(questA, 321),
+                EditTypeStatistics(questB, 33),
+            ),
+            currentWeekCountries = listOf(
+                CountryStatistics("AT", 999, 88),
+                CountryStatistics("IT", 99, null),
+            ),
             lastUpdate = 9999999,
             isAnalyzing = false
         ))
@@ -140,9 +164,18 @@ class StatisticsControllerTest {
             CountryStatistics("DE", 12, 5),
             CountryStatistics("US", 43, null),
         ))
+        verify(currentWeekEditTypeStatisticsDao).replaceAll(mapOf(
+            "TestQuestTypeA" to 321,
+            "TestQuestTypeB" to 33
+        ))
+        verify(currentWeekCountryStatisticsDao).replaceAll(listOf(
+            CountryStatistics("AT", 999, 88),
+            CountryStatistics("IT", 99, null),
+        ))
         verify(editor).putInt(Prefs.USER_DAYS_ACTIVE, 333)
         verify(editor).putBoolean(Prefs.IS_SYNCHRONIZING_STATISTICS, false)
         verify(editor).putInt(Prefs.USER_GLOBAL_RANK, 999)
+        verify(editor).putInt(Prefs.USER_GLOBAL_RANK_CURRENT_WEEK, 111)
         verify(editor).putLong(Prefs.USER_LAST_TIMESTAMP_ACTIVE, 9999999)
         verify(listener).onUpdatedAll()
     }
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParserTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParserTest.kt
index 0cfe9c54bd2..bbe8a6cd53b 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParserTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParserTest.kt
@@ -22,10 +22,20 @@ class StatisticsParserTest {
                 CountryStatistics("DE", 8, null),
                 CountryStatistics("US", 7, 123),
             ),
-            2345,
-            78,
-            Instant.parse("2007-12-03T10:15:30+01:00").toEpochMilliseconds(),
-            false
+            rank = 2345,
+            daysActive = 78,
+            currentWeekRank = 3,
+            currentWeekTypes = listOf(
+                EditTypeStatistics(questA, 9),
+                EditTypeStatistics(questB, 99),
+                EditTypeStatistics(questC, 999),
+            ),
+            currentWeekCountries = listOf(
+                CountryStatistics("AT", 5, 666),
+                CountryStatistics("IT", 4, null),
+            ),
+            lastUpdate = Instant.parse("2007-12-03T10:15:30+01:00").toEpochMilliseconds(),
+            isAnalyzing = false
         ),
         StatisticsParser(listOf("TestQuestTypeCAlias" to "TestQuestTypeC")).parse("""
         {
@@ -42,6 +52,19 @@ class StatisticsParserTest {
                 "US": "123",
             },
             "rank": "2345",
+            "currentWeekRank": "3",
+            "currentWeekQuestTypes": {
+                "TestQuestTypeA": "9",
+                "TestQuestTypeB": "99",
+                "TestQuestTypeCAlias": "999",
+            },
+            "currentWeekCountries": {
+                "IT": 4,
+                "AT": 5,
+            },
+            "currentWeekCountryRanks": {
+                "AT": 666
+            },
             "daysActive": "78",
             "lastUpdate": "2007-12-03T10:15:30+01:00",
             "isAnalyzing": "false"


Don't call MapDataWithEditsSource listeners if nothing changed

fixes #4077

This is a longer version of what was linked in https://github.com/streetcomplete/StreetComplete/issues/4077#issuecomment-1265693266, but it's not doing more work and I think it's easier to read that way.

Improve performance on upload: Notice if nothing changed
## Current behavior

Whenever the map data changes (download, upload) or the user creates or deletes a local edit, `MapDataWithEditsSource` notifies its listeners about the changed map data.

Most notably amongst the listeners is the `OsmQuestController` which will re-scan the changed element(s) for quests and in turn triggers the map to refresh displaying the quests. This is done for example when the user answered a quest and it takes relatively long (a few hundred milliseconds up to a second or so).

After an edit has been uploaded, the copy of the original OSM element is updated and the edit is deleted. This triggers `MapDataWithEditsSource` to rebuild the cache (=the local changes applied to the original OSM map data) and in turn triggers notifying the listeners about changed.

## The Issue

If the element the edit has been applied to and uploaded has not been changed in the meantime by someone else, which consistitutes about 99% of the times,

- the previous original OSM element + applied edit
- and the updated OSM element after upload

are identical. In the case that the element didn't change at all, notifying the listeners that the map data changed is superfluous and could theoretically be avoided. Currently it is not avoided, which means not only are the local changes rebuilt on upload of each singular edit (I think this is not avoidable) but that updated element is scanned for quests plus the map being updated as a consequence of scanning for quests. Again, for each singular edit.

At least what could be done is to reduce the number of elements for which the listener is called to those elements that actually changed. This requires to compare the edited elements before `rebuildLocalChanges` has been called with the edited elements after it has been called.

## Challenges

- since edits and OSM elements are managed independently (`MapDataController`, `ElementEditsController`) and thus updating one and deleting the other during upload theoretically constitutes of two separate operations, it is possible that `MapDataWithEditsSource` is actually notified of a change in data twice for the same uploaded element. Needs to be confirmed is this actually the case

## Remarks

If you intend to look into this, post here first because @Helium314 vaguely registered interest to busy himself with it before.

@Helium314, I think you started some implementation in the frame of another ticket already?

I noticed that the solution for this ticket will not only improve performance on upload, but also on the normal usage for most users, as auto-upload after each change is active. So currently after each quest solution/edit for users that have auto-upload enabled, the quest icons and map data (in case of overlays displayed) are updated twice in fast succession, each with usually the same data. Thus, the solution to this ticket will speed up this normal usage of the app by a factor of 2.
> @Helium314, I think you started some implementation in the frame of another ticket already?

No, I had sone nothing on this.
But I just had a look at https://github.com/streetcomplete/StreetComplete/blob/6e4cd3d1351bae9068fb0d2032509fc021030d60/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt#L61
So before `rebuildLocalChanges`, we need to check whether all keys in `deleted` are in `deletedElements`, and whether all elements and geometries in `updated` are already in `updatedElements`/`updatedGeometries`.
i.e.
```kotlin
val nothingChanged = deletedElements.containsAll(deleted) && updated.all { element ->
    val key = ElementKey(element.type, element.id)
    updatedElements[key] == element && updatedGeometries[key] == updated.getGeometry(element.type, element.id)
}
```
Though elements should probably be allowed to be equal except for the timestamp and maybe version.

If all is as expected, there is no need to call `callOnUpdated`... right?
Yes, exactly, on all points, including the note about `version` and `timestamp`. Hence, the code above won't work because IIRC the `version` of the local element is never updated and the `timestamp` will always differ by at least by a few milliseconds.

I am not sure if it is necessary to compare the geometry. If the geometry changed, it means some other node changed too, so that should be caught by the first check. The geometry is aggregate/processed data from the raw OSM data anyway.

So, only everything except that should be compared. And if it is the same, the elements in deletedElements/updatedElements/updatedGeometries should be **removed** from `MapDataWithEditsSource` because the most current element is now from `MapDataController`. Plus of course, most importantly,

> If all is as expected, there is no need to call callOnUpdated... right?

this.
> I am not sure if it is necessary to compare the geometry. If the geometry changed, it means some other node changed too, so that should be caught by the first check.

Right, this is probably not necessary.

> And if it is the same, the elements in deletedElements/updatedElements/updatedGeometries should be **removed** from `MapDataWithEditsSource` because the most current element is now from `MapDataController`.

Actually deletedElements/updatedElements/updatedGeometries are cleared as part of `rebuildLocalChanges`, which is why I mentioned that this check should happen before `rebuildLocalChanges`.
And we need to rebuild anyway, because there may be other local changes on those elements. (at least currently I don't see good a way to avoid rebuilding).

When uploading, the check will only find if a single edit was done on that element.
If there were 2 edits to the element (one was just synced, one remains locally), `nothingChanged` will return false because the updated element is missing the second change.
But I think catching this case is probably much more work...
Hmm, good point. So the current solution would only work when auto-upload is on, where every single change is uploaded immediately.

I see two avenues to explore here:
1. Can the `rebuildLocalChanges` be made so that not the complete local changes must be rebuild but a few elements (what has been passed into that function) too? I.e. the local changes are being "re-based"
2. Can at least update call to the listeners be avoided by detecting that none of the elements passed into the function have changed?

> 2. Can at least update call to the listeners be avoided by detecting that none of the elements passed into the function have changed?

That _might_ be simply:
before rebuilding local changes: `val e = updated.map { get(it.type, it.id) }`
right before `callOnUpdated`: `val nothingChanged = e.all { it == get(it.type, it.id) }`
Also, even if it only works if just one edit has been made on an element, in many cases it will still be worth it because even if you have thousands of local edits, they might just as well be each on different elements and not (most) on top of each other on the same element(s)
first version: https://github.com/streetcomplete/StreetComplete/commit/4cdd85835beda00e4be3386af4c12d067abbe5e1
second version: https://github.com/streetcomplete/StreetComplete/compare/master...Helium314:StreetComplete:avoid_calling_listeners

I didn't even test whether it compiles, and may have overlooked something...
[Edit: small fix for the second version... but even if this works: it looks rather ugly and is hard to read]
Hmm, actually the second check could be done right after `rebuildLocalChanges`, and `return` immediately if nothing was changed.
I did some test:
Store the `somethingMayHaveChanged` result in a companion object, and if it's false `OsmQuestController.onUpdated` logs whether `deleted.isEmpty()` and `previousQuests` and `quests` contain the same keys / positions.
Every time this was logged, both came out true, i.e. no quests changed.
Sorry, you lost me there. I don't understand how this test was relevant. If no elements changed, of course the quests also do not change(?)
The test was
* whether the checks actually ever return that nothing is changed (nothing overlooked)
* that if they detect that nothing has changed, then really no quests change

./gradlew :app:testDebugUnitTest --tests "de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSourceTest"

diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt
index 844147c20a8..8e6f803918d 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt
@@ -62,8 +62,35 @@ class MapDataWithEditsSource internal constructor(
             val modifiedElements = ArrayList<Pair<Element, ElementGeometry?>>()
             val modifiedDeleted = ArrayList<ElementKey>()
             synchronized(this) {
+                /* We don't want to callOnUpdated if none of the changes affects map data provided
+                 * by MapDataWithEditsSource.
+                 * This is the case if
+                 *  * All keys in deleted are already in deletedElements.
+                 *  * The modified versions of all elements in updated are the same before and after
+                 *    rebuildLocalChanges, except for the timestamp (expected to have few ms
+                 *    difference) and version (never updated locally).
+                 */
+                val deletedIsUnchanged = deletedElements.containsAll(deleted)
+                val elementsThatMightHaveChangedByKey = updated.mapNotNull { element ->
+                    val key = ElementKey(element.type, element.id)
+                    if (element.isEqualExceptVersionAndTimestamp(updatedElements[key]))
+                        null // we already have the updated version, so this element is unchanged
+                    else
+                        key to get(element.type, element.id) // elementKey and element as provided by MapDataWithEditsSource
+                }
+
                 rebuildLocalChanges()

+                /* nothingChanged can be false at this point when e.g. there are two edits on the
+                   same element, and onUpdated is called after the first edit is uploaded. */
+                val nothingChanged = deletedIsUnchanged && elementsThatMightHaveChangedByKey.all {
+                    val updatedElement = get(it.first.type, it.first.id)
+                    // old and new elements are equal except version and timestamp, or both are null
+                    it.second?.isEqualExceptVersionAndTimestamp(updatedElement) ?: (updatedElement == null)
+                }
+                if (nothingChanged)
+                    return
+
                 for (element in updated) {
                     val key = ElementKey(element.type, element.id)
                     // an element contained in the update that was deleted by an edit shall be deleted
@@ -438,3 +465,10 @@ class MapDataWithEditsSource internal constructor(
         listeners.forEach { it.onCleared() }
     }
 }
+
+private fun Element.isEqualExceptVersionAndTimestamp(element: Element?): Boolean =
+    id == element?.id && tags == element.tags && type == element.type && when (this) {
+        is Node -> position == (element as Node).position
+        is Way -> nodeIds == (element as Way).nodeIds
+        is Relation -> members == (element as Relation).members
+    }

diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSourceTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSourceTest.kt
index 77b07a834a3..50af86e1703 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSourceTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSourceTest.kt
@@ -35,6 +35,7 @@ import org.junit.Test
 import org.mockito.ArgumentMatchers.anyLong
 import org.mockito.Mockito.verify
 import org.mockito.Mockito.verifyNoInteractions
+import org.mockito.Mockito.verifyNoMoreInteractions

 class MapDataWithEditsSourceTest {

@@ -977,6 +978,117 @@ class MapDataWithEditsSourceTest {
         verify(listener).onUpdated(eq(expectedMapDataWithGeometry), eq(expectedDeletions))
     }

+    @Test
+    fun `does not call onUpdated when all deleted elements are already deleted`() {
+        mapDataChangesAre(deletions = listOf(node(4)))
+
+        val s = create()
+        val listener = mock<MapDataWithEditsSource.Listener>()
+        s.addListener(listener)
+
+        val updatedMapData = MutableMapDataWithGeometry()
+        val deletions = listOf(ElementKey(NODE, 4))
+
+        mapDataListener.onUpdated(updatedMapData, deletions)
+
+        verifyNoInteractions(listener)
+    }
+
+    @Test
+    fun `does call onUpdated when not all deleted elements are already deleted`() {
+        mapDataChangesAre(deletions = listOf(node(4)))
+
+        val s = create()
+        val listener = mock<MapDataWithEditsSource.Listener>()
+        s.addListener(listener)
+
+        val updatedMapData = MutableMapDataWithGeometry()
+        val deletions = listOf(ElementKey(NODE, 3), ElementKey(NODE, 4))
+        mapDataListener.onUpdated(updatedMapData, deletions)
+
+        val expectedDeletions = listOf(
+            ElementKey(NODE, 3),
+            ElementKey(NODE, 4)
+        )
+        verify(listener).onUpdated(eq(updatedMapData), eq(expectedDeletions))
+    }
+
+    @Test
+    fun `does not call onUpdated when all updated elements stayed the same`() {
+        val ndModified = node(1, p(0.3, 0.0))
+        val pModified = ElementGeometryEntry(NODE, 1, pGeom(0.3, 0.0))
+        val ndModified4 = node(4, p(0.5, 0.4))
+        val pModified4 = ElementGeometryEntry(NODE, 4, pGeom(0.5, 0.4))
+
+        mapDataChangesAre(modifications = listOf(ndModified, ndModified4))
+
+        val s = create()
+        val listener = mock<MapDataWithEditsSource.Listener>()
+        s.addListener(listener)
+
+        val updatedMapData = MutableMapDataWithGeometry(
+            elements = listOf(ndModified, ndModified4),
+            geometryEntries = listOf(pModified, pModified4)
+        )
+        mapDataListener.onUpdated(updatedMapData, emptyList())
+
+        verifyNoInteractions(listener)
+    }
+
+    @Test
+    fun `does not call onUpdated when all updated elements stayed the same except for version and timestamp`() {
+        val ndModified = node(1, p(0.3, 0.0))
+        val pModified = ElementGeometryEntry(NODE, 1, pGeom(0.3, 0.0))
+        val ndModified4 = node(4, p(0.5, 0.4))
+        val pModified4 = ElementGeometryEntry(NODE, 4, pGeom(0.5, 0.4))
+
+        val ndModifiedWithVersion = node(1, p(0.3, 0.0), version = 2)
+        val ndModified4WithTimestamp = node(4, p(0.5, 0.4), timestamp = 123L)
+
+        val s = create()
+        val listener = mock<MapDataWithEditsSource.Listener>()
+        s.addListener(listener)
+
+        editsControllerNotifiesMapDataChangesAdded(modifications = listOf(ndModified, ndModified4))
+        originalElementsAre(ndModifiedWithVersion, ndModified4WithTimestamp)
+        verify(listener).onUpdated(any(), any())
+
+        val updatedMapData = MutableMapDataWithGeometry(
+            elements = listOf(ndModifiedWithVersion, ndModified4WithTimestamp),
+            geometryEntries = listOf(pModified, pModified4)
+        )
+        mapDataListener.onUpdated(updatedMapData, emptyList())
+
+        verifyNoMoreInteractions(listener)
+    }
+
+    @Test
+    fun `does call onUpdated when not all updated elements stayed the same`() {
+        val ndModified = node(1, p(0.3, 0.0))
+        val ndModified4 = node(4, p(0.5, 0.4))
+        val pModified4 = ElementGeometryEntry(NODE, 4, pGeom(0.5, 0.4))
+
+        val ndModifiedMoved = node(1, p(0.3, 0.1))
+        val pModifiedMoved = ElementGeometryEntry(NODE, 1, pGeom(0.3, 0.1))
+
+        val s = create()
+        val listener = mock<MapDataWithEditsSource.Listener>()
+        s.addListener(listener)
+
+        editsControllerNotifiesMapDataChangesAdded(modifications = listOf(ndModified, ndModified4))
+        originalElementsAre(ndModifiedMoved, ndModified4)
+        verify(listener).onUpdated(any(), any())
+
+        originalElementsAre(ndModified4, ndModifiedMoved)
+        val updatedMapData = MutableMapDataWithGeometry(
+            elements = listOf(ndModifiedMoved, ndModified4),
+            geometryEntries = listOf(pModifiedMoved, pModified4)
+        )
+        mapDataListener.onUpdated(updatedMapData, emptyList())
+
+        verify(listener).onUpdated(eq(updatedMapData), eq(emptyList()))
+    }
+
     //endregion

     //region MapDataController.Listener ::onReplacedForBBox