chore(dates): remove the dead date_plan_preferences plumbing (Batch F)

Dead code isn't inert — this carried a live-looking E2EE encryptor and a rules
block, inviting the next engineer to extend a surface nothing reads (which is
exactly how N-002 happened: writes into a collection with no reader). Removed,
all verified caller-dead first: the Firestore preference methods + mapper on
FirestoreDatePlanDataSource, the repository preference surface (interface +
impl + the assemblePlanSuggestion placeholder, whose body was literally
`val x = /* comment */` swallowing a return — dead AND weird), the
DatePlanPreference/DatePlanSuggestion domain models, DatePlanPreferenceDao +
its DI provider + the AppDatabase accessor, and the firestore.rules block
(collection is now default-deny, strictly safer; stray pre-R15 docs are
orphaned — launch-checklist note in Future.md). DateBuilderViewModel's
vestigial savePreference() renamed createPlan() — it has created real PLANNED
plans since the N-002 fix.

The one deliberate KEEP: DatePlanPreferenceEntity stays registered in
AppDatabase. Room's identity hash is computed from the SCHEMA, and the DB ships
via createFromAsset with no migrations — dropping the entity changes the hash
and crashes every install at first DB open. The entity now carries a loud KDoc
saying exactly that (and the AppDatabase comment points at it), so nobody
"cleans it up" without regenerating the asset DB. Removing DAOs/methods is
hash-neutral; proven live: fresh install on-device, DB opens clean, DB-served
content renders.

Also repaired collateral from my own removal script: it over-cut plansRef
(caught by compile, restored verbatim) — and the repo impl no longer injects
the same datasource twice under two names.

Live post-removal: Create Plan saves without error or PERMISSION_DENIED (the
surviving savePlan path). Caveat, stated honestly: the Home "Date coming up"
tile wasn't re-observed because the Compose date picker resists uiautomator
(cells expose content-desc only) and a today-dated plan is excluded by design
(scheduledDate > now) — the tile logic is untouched by this batch and was
live-verified in R15. Full unit suite green, assembleDebug clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-15 20:34:54 -05:00
parent 7341f64ae2
commit bc3ad2650b
13 changed files with 33 additions and 271 deletions

View File

@ -284,6 +284,14 @@ no `auth.user().onDelete`). Deploy-day learnings + deferred items:
## Roadmap — features & strategy (consolidated from the old `FUTURE.md`, 2026-06-28) ## Roadmap — features & strategy (consolidated from the old `FUTURE.md`, 2026-06-28)
- **Two-sided date planning (deliberate not-now, 2026-07-16).** The original Date Builder vision — each
partner records preferences (time/budget/duration) and the app assembles a plan from both — was never
built past a write-only stub (N-002). The dead plumbing (`date_plan_preferences` datasource/DAO/rules,
the placeholder `assemblePlanSuggestion`) was REMOVED in Batch F; the Room table `date_plan_preferences`
remains schema-frozen in the asset DB (identity hash) but has no reader/writer. If this returns, design
it fresh: both-partner input, a real merge, and a reader surface — don't resurrect the old plumbing.
Launch checklist: one admin sweep for stray pre-R15 `date_plan_preferences` docs (now default-deny).
**Unbuilt games (Play Hub is live; these remain):** **Unbuilt games (Play Hub is live; these remain):**
- **Would You Rather / Truth or Dare** — tiered sweet→spicy, consent-gated; reuses the deck + match engine. Strong **premium "spicy" tier** lever. - **Would You Rather / Truth or Dare** — tiered sweet→spicy, consent-gated; reuses the deck + match engine. Strong **premium "spicy" tier** lever.
- **Daily Sync / Rose-Bud-Thorn** — one-tap emotional check-in, see partner's; small/new, free retention driver. - **Daily Sync / Rose-Bud-Thorn** — one-tap emotional check-in, see partner's; small/new, free retention driver.

View File

@ -18,6 +18,8 @@ import app.closer.data.local.entity.QuestionEntity
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun questionDao(): QuestionDao abstract fun questionDao(): QuestionDao
abstract fun categoryDao(): CategoryDao abstract fun categoryDao(): CategoryDao
abstract fun datePlanPreferenceDao(): DatePlanPreferenceDao // No DAO for DatePlanPreferenceEntity: the table is schema-frozen dead weight (see its KDoc) —
// it stays in `entities` only because removing it changes the Room identity hash and breaks
// the createFromAsset DB at first open.
abstract fun datePlanDao(): DatePlanDao abstract fun datePlanDao(): DatePlanDao
} }

View File

@ -1,32 +0,0 @@
package app.closer.data.local
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.closer.data.local.entity.DatePlanPreferenceEntity
@Dao
interface DatePlanPreferenceDao {
@Query("SELECT * FROM date_plan_preferences WHERE id = :id LIMIT 1")
suspend fun getById(id: String): DatePlanPreferenceEntity?
@Query("SELECT * FROM date_plan_preferences WHERE couple_id = :coupleId ORDER BY updated_at DESC")
suspend fun getByCoupleId(coupleId: String): List<DatePlanPreferenceEntity>
@Query("SELECT * FROM date_plan_preferences WHERE couple_id = :coupleId AND date_idea_id = :dateIdeaId LIMIT 1")
suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreferenceEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(preference: DatePlanPreferenceEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(pREFERENCES: List<DatePlanPreferenceEntity>)
@Delete
suspend fun delete(preference: DatePlanPreferenceEntity)
@Query("DELETE FROM date_plan_preferences WHERE couple_id = :coupleId")
suspend fun deleteByCoupleId(coupleId: String)
}

View File

@ -5,12 +5,15 @@ import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
/** /**
* Room entity for partner date plan preferences. * SCHEMA-FROZEN DEAD WEIGHT DO NOT REMOVE (and do not resurrect).
* *
* Stored locally in Room. Synced to Firestore when explicitly shared/scheduled. * Nothing reads or writes this table: the preference feature it served was removed as dead code
* Both partners contribute preferences; the Builder uses them to assemble a plan. * (its DAO, repository methods, Firestore datasource methods and rules block are gone). The entity
* * itself must stay registered in [app.closer.data.local.AppDatabase] because Room's identity hash is
* Table: date_plan_preferences * computed from the SCHEMA, and the database ships via createFromAsset with no migrations removing
* this entity changes the hash and crashes every install at first DB open until the asset DB is
* regenerated (seed/build_db.py) and re-verified. If the two-sided preferences vision ever returns,
* design it fresh (see Future.md) rather than re-wiring this table.
*/ */
@Entity(tableName = "date_plan_preferences") @Entity(tableName = "date_plan_preferences")
data class DatePlanPreferenceEntity( data class DatePlanPreferenceEntity(

View File

@ -3,7 +3,6 @@ package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.DatePlan import app.closer.domain.model.DatePlan
import app.closer.domain.model.DatePlanPreference
import app.closer.domain.model.DatePlanStatus import app.closer.domain.model.DatePlanStatus
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
@ -19,11 +18,9 @@ import javax.inject.Singleton
/** /**
* Firestore data source for date plan preferences and plans. * Firestore data source for date plan preferences and plans.
* *
* Path layout: * Path layout: couples/{coupleId}/date_plans/{planId} plans, synced when explicitly
* - couples/{coupleId}/date_plan_preferences/{userId} per-partner preferences * shared/scheduled. (The old per-partner date_plan_preferences surface was removed as dead code;
* - couples/{coupleId}/date_plans/{planId} complete plans generated by the builder * the collection is default-deny in rules and no longer exists in code.)
*
* Local-first: Preferences stored in Room. Plans synced to Firestore when explicitly shared/scheduled.
*/ */
@Singleton @Singleton
class FirestoreDatePlanDataSource @Inject constructor( class FirestoreDatePlanDataSource @Inject constructor(
@ -31,53 +28,10 @@ class FirestoreDatePlanDataSource @Inject constructor(
private val encryptionManager: CoupleEncryptionManager, private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor private val fieldEncryptor: FieldEncryptor
) { ) {
private fun preferencesRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.DATE_PLAN_PREFERENCES)
private fun plansRef(coupleId: String) = private fun plansRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId) db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.DATE_PLANS) .collection(FirestoreCollections.Couples.DATE_PLANS)
// ─── Preference methods ──────────────────────────────────────────────────
suspend fun recordPreference(coupleId: String, preference: DatePlanPreference) {
// Strict E2EE: user-entered details are encrypted. dateIdeaId stays plaintext
// (queried via whereEqualTo); dates/timestamps stay plaintext (structural).
val aead = encryptionManager.requireAead(coupleId)
val path = preferencesRef(coupleId).document()
val data = mapOf(
"dateIdeaId" to preference.dateIdeaId,
"preferredDate" to preference.preferredDate,
"preferredTime" to fieldEncryptor.encrypt(preference.preferredTime, aead, coupleId),
"budget" to fieldEncryptor.encrypt(preference.budget.toString(), aead, coupleId),
"duration" to fieldEncryptor.encrypt(preference.duration, aead, coupleId),
"createdAt" to preference.createdAt,
"updatedAt" to preference.updatedAt
)
path.set(data, SetOptions.merge()).await()
}
suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreference? {
// Query for preferences on this date idea
val snap = preferencesRef(coupleId)
.whereEqualTo("dateIdeaId", dateIdeaId)
.limit(1)
.get().await()
return snap.documents.firstOrNull()?.toDatePlanPreference(coupleId)
}
fun observePreferences(coupleId: String): Flow<List<DatePlanPreference>> {
return callbackFlow {
val listener = preferencesRef(coupleId)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { it.toDatePlanPreference(coupleId) })
}
awaitClose { listener.remove() }
}
}
// ─── Plan methods ──────────────────────────────────────────────────────── // ─── Plan methods ────────────────────────────────────────────────────────
suspend fun createPlan(coupleId: String, plan: DatePlan): String { suspend fun createPlan(coupleId: String, plan: DatePlan): String {
@ -145,21 +99,6 @@ class FirestoreDatePlanDataSource @Inject constructor(
// ─── Mappers ───────────────────────────────────────────────────────────── // ─── Mappers ─────────────────────────────────────────────────────────────
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlanPreference(coupleId: String): DatePlanPreference? {
val dateIdeaId = getString("dateIdeaId") ?: return null
val aead = encryptionManager.aeadFor(coupleId)
return DatePlanPreference(
id = id,
coupleId = coupleId,
dateIdeaId = dateIdeaId,
preferredDate = (get("preferredDate") as? Number)?.toLong() ?: 0L,
preferredTime = fieldEncryptor.decryptForDisplay(getString("preferredTime"), aead, coupleId) ?: "",
budget = fieldEncryptor.decrypt(getString("budget"), aead, coupleId)?.toIntOrNull() ?: 0,
duration = fieldEncryptor.decryptForDisplay(getString("duration"), aead, coupleId) ?: "",
createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L,
updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L
)
}
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlan(coupleId: String): DatePlan? { private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlan(coupleId: String): DatePlan? {
val dateIdeaId = getString("dateIdeaId") ?: return null val dateIdeaId = getString("dateIdeaId") ?: return null

View File

@ -1,13 +1,9 @@
package app.closer.data.repository package app.closer.data.repository
import app.closer.data.local.DatePlanDao import app.closer.data.local.DatePlanDao
import app.closer.data.local.DatePlanPreferenceDao
import app.closer.data.local.entity.DatePlanEntity import app.closer.data.local.entity.DatePlanEntity
import app.closer.data.local.entity.DatePlanPreferenceEntity
import app.closer.data.remote.FirestoreDatePlanDataSource import app.closer.data.remote.FirestoreDatePlanDataSource
import app.closer.domain.model.DatePlan import app.closer.domain.model.DatePlan
import app.closer.domain.model.DatePlanPreference
import app.closer.domain.model.DatePlanSuggestion
import app.closer.domain.model.DatePlanStatus import app.closer.domain.model.DatePlanStatus
import app.closer.domain.repository.DatePlanRepository import app.closer.domain.repository.DatePlanRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -17,44 +13,15 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Implementation of [DatePlanRepository]. * Implementation of [DatePlanRepository]: local-first plans (Room) synced to Firestore when
* * explicitly shared/scheduled.
* Local-first: Preferences stored in Room. Plans synced to Firestore when
* explicitly shared/scheduled. Both partners contribute preferences; the
* Builder assembles complete plans from those preferences.
*/ */
@Singleton @Singleton
class DatePlanRepositoryImpl @Inject constructor( class DatePlanRepositoryImpl @Inject constructor(
private val preferenceDataSource: FirestoreDatePlanDataSource,
private val planDataSource: FirestoreDatePlanDataSource, private val planDataSource: FirestoreDatePlanDataSource,
private val preferenceDao: DatePlanPreferenceDao,
private val planDao: DatePlanDao private val planDao: DatePlanDao
) : DatePlanRepository { ) : DatePlanRepository {
// ─── Preference methods ──────────────────────────────────────────────────
override suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreference? {
// First check local cache
val local = preferenceDao.getPreference(coupleId, dateIdeaId)
if (local != null) return local.toDomain()
// Fall back to Firestore
return preferenceDataSource.getPreference(coupleId, dateIdeaId)
}
override suspend fun savePreference(preference: DatePlanPreference) {
// Store in Room for local-first access
val entity = preference.toEntity()
preferenceDao.insert(entity)
// Sync to Firestore
preferenceDataSource.recordPreference(preference.coupleId, preference)
}
override fun observePreferences(coupleId: String): Flow<List<DatePlanPreference>> {
return preferenceDataSource.observePreferences(coupleId)
}
// ─── Plan methods ──────────────────────────────────────────────────────── // ─── Plan methods ────────────────────────────────────────────────────────
override suspend fun getPlan(planId: String): DatePlan? { override suspend fun getPlan(planId: String): DatePlan? {
@ -113,26 +80,6 @@ class DatePlanRepositoryImpl @Inject constructor(
// In a full implementation, we would fetch the plan's coupleId first // In a full implementation, we would fetch the plan's coupleId first
} }
// ─── Builder methods ─────────────────────────────────────────────────────
override suspend fun assemblePlanSuggestion(
coupleId: String,
dateIdeaId: String
): DatePlanSuggestion? {
// Get preferences from both partners for this date idea
val partnerAPreference = preferenceDao.getPreference(coupleId, dateIdeaId)
val partnerBPreference = /* Get the other partner's preference */
// For now, return a placeholder suggestion
// In a full implementation, this would merge preferences from both partners
return DatePlanSuggestion(
activity = "Based on the selected date idea",
food = "Suggested dining option based on budget",
conversationPrompts = listOf("What made this date special?"),
optionalChallenge = "Try something new together"
)
}
// ─── Sanitization ──────────────────────────────────────────────────────── // ─── Sanitization ────────────────────────────────────────────────────────
private fun DatePlan.sanitized(): DatePlan = copy( private fun DatePlan.sanitized(): DatePlan = copy(
@ -156,30 +103,6 @@ class DatePlanRepositoryImpl @Inject constructor(
const val MAX_PROMPTS = 20 const val MAX_PROMPTS = 20
} }
private fun DatePlanPreferenceEntity.toDomain(): DatePlanPreference = DatePlanPreference(
id = id,
coupleId = coupleId,
dateIdeaId = dateIdeaId,
preferredDate = preferredDate,
preferredTime = preferredTime,
budget = budget,
duration = duration,
createdAt = createdAt,
updatedAt = updatedAt
)
private fun DatePlanPreference.toEntity(): DatePlanPreferenceEntity = DatePlanPreferenceEntity(
id = id,
coupleId = coupleId,
dateIdeaId = dateIdeaId,
preferredDate = preferredDate,
preferredTime = preferredTime,
budget = budget,
duration = duration,
createdAt = createdAt,
updatedAt = updatedAt
)
private fun DatePlanEntity.toDomain(): DatePlan = DatePlan( private fun DatePlanEntity.toDomain(): DatePlan = DatePlan(
id = id, id = id,
coupleId = coupleId, coupleId = coupleId,

View File

@ -40,10 +40,6 @@ object DatabaseModule {
@Singleton @Singleton
fun provideCategoryDao(db: AppDatabase) = db.categoryDao() fun provideCategoryDao(db: AppDatabase) = db.categoryDao()
@Provides
@Singleton
fun provideDatePlanPreferenceDao(db: AppDatabase) = db.datePlanPreferenceDao()
@Provides @Provides
@Singleton @Singleton
fun provideDatePlanDao(db: AppDatabase) = db.datePlanDao() fun provideDatePlanDao(db: AppDatabase) = db.datePlanDao()

View File

@ -1,21 +0,0 @@
package app.closer.domain.model
/**
* A partner's preferences for assembling a date plan.
*
* Both partners contribute preferences; the Builder uses them to assemble
* a complete plan with activity, food, conversation prompts, and optional challenge.
*
* Stored locally in Room. Synced to Firestore when explicitly shared/scheduled.
*/
data class DatePlanPreference(
val id: String = "",
val coupleId: String = "",
val dateIdeaId: String = "",
val preferredDate: Long = 0L,
val preferredTime: String = "",
val budget: Int = 0,
val duration: String = "",
val createdAt: Long = 0L,
val updatedAt: Long = 0L
)

View File

@ -1,19 +0,0 @@
package app.closer.domain.model
/**
* A complete plan suggestion generated by the Date Builder.
*
* Contains the activity, food, conversation prompts, and optional challenge
* that are assembled from partner preferences and the selected date idea.
*
* @property activity Recommended activity description.
* @property food Recommended food/dining option.
* @property conversationPrompts List of conversation question IDs or prompts.
* @property optionalChallenge Optional challenge suggestion for the date.
*/
data class DatePlanSuggestion(
val activity: String = "",
val food: String = "",
val conversationPrompts: List<String> = emptyList(),
val optionalChallenge: String? = null
)

View File

@ -1,31 +1,17 @@
package app.closer.domain.repository package app.closer.domain.repository
import app.closer.domain.model.DatePlan import app.closer.domain.model.DatePlan
import app.closer.domain.model.DatePlanPreference
import app.closer.domain.model.DatePlanSuggestion
import kotlinx.coroutines.flow.Flow
/** /**
* Repository for the Date Builder feature. * Repository for the Date Builder feature: real, schedulable [DatePlan]s synced to Firestore.
* *
* Responsibilities: * The old two-sided "preference" surface (each partner records preferences, a plan is assembled
* - Store partner preferences locally (Room) * from both) was removed as dead code nothing ever read it, and "Create Plan" has written real
* - Assemble complete date plans from preferences * PLANNED plans since the N-002 fix. If that two-sided product vision returns, design it fresh
* - Sync plans to Firestore when shared/scheduled * rather than resurrecting the old plumbing (see Future.md).
*/ */
interface DatePlanRepository { interface DatePlanRepository {
// ─── Preference methods ──────────────────────────────────────────────────
/** Get a partner's preference for a date idea. */
suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreference?
/** Record or update a partner's preference. */
suspend fun savePreference(preference: DatePlanPreference)
/** Observe all preferences for a couple. */
fun observePreferences(coupleId: String): Flow<List<DatePlanPreference>>
// ─── Plan methods ──────────────────────────────────────────────────────── // ─── Plan methods ────────────────────────────────────────────────────────
/** Get a date plan by ID. */ /** Get a date plan by ID. */
@ -45,12 +31,4 @@ interface DatePlanRepository {
/** Delete a date plan. */ /** Delete a date plan. */
suspend fun deletePlan(planId: String) suspend fun deletePlan(planId: String)
// ─── Builder methods ─────────────────────────────────────────────────────
/** Assemble a date plan suggestion from partner preferences. */
suspend fun assemblePlanSuggestion(
coupleId: String,
dateIdeaId: String
): DatePlanSuggestion?
} }

View File

@ -88,7 +88,7 @@ fun DateBuilderScreen(
onTimeChange = viewModel::updateTime, onTimeChange = viewModel::updateTime,
onBudgetChange = viewModel::updateBudget, onBudgetChange = viewModel::updateBudget,
onDurationChange = viewModel::updateDuration, onDurationChange = viewModel::updateDuration,
onSave = { viewModel.savePreference() }, onSave = { viewModel.createPlan() },
onBack = { onNavigate("back") } onBack = { onNavigate("back") }
) )
SnackbarHost( SnackbarHost(

View File

@ -42,7 +42,7 @@ class DateBuilderViewModel @Inject constructor(
_uiState.update { it.copy(duration = duration) } _uiState.update { it.copy(duration = duration) }
} }
fun savePreference() { fun createPlan() {
val state = _uiState.value val state = _uiState.value
viewModelScope.launch { viewModelScope.launch {

View File

@ -601,23 +601,8 @@ service cloud.firestore {
allow update, delete: if false; allow update, delete: if false;
} }
// Date plan preferences: per-partner preferences for building date plans. // (date_plan_preferences removed 2026-07-16 — the per-partner preference surface was dead code;
// Both members can read; either member can write a preference document. // the collection is deliberately default-deny. Stray pre-R15 docs are orphaned: launch checklist.)
// Document IDs are Firestore auto-IDs (not user IDs).
match /date_plan_preferences/{prefId} {
allow read: if isCouplesMember(coupleId);
allow create, update: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasAll(['dateIdeaId', 'createdAt', 'updatedAt'])
&& request.resource.data.keys().hasOnly([
'dateIdeaId', 'preferredDate', 'preferredTime',
'budget', 'duration', 'createdAt', 'updatedAt'
])
// Strict E2EE: user-entered details are ciphertext (dateIdeaId/dates stay plaintext for queries).
&& cipherOrAbsent(request.resource.data, 'preferredTime')
&& cipherOrAbsent(request.resource.data, 'budget')
&& cipherOrAbsent(request.resource.data, 'duration');
allow delete: if false;
}
// Date plans: complete plans assembled from partner preferences. // Date plans: complete plans assembled from partner preferences.
// Both members can read and delete; writes are field-validated. // Both members can read and delete; writes are field-validated.