feat: add memory capsule generator for saving meaningful moments (batch v1.0.9)

This commit is contained in:
null 2026-06-19 22:47:36 -05:00
parent a500b86621
commit 5698e5436a
2 changed files with 778 additions and 0 deletions

View File

@ -0,0 +1,345 @@
package app.closer.domain
import app.closer.domain.model.MemoryCapsule
import app.closer.domain.model.MemoryCapsuleSource
import app.closer.domain.model.MemoryChallengeEvent
import app.closer.domain.model.MemoryDateIdeaEvent
import app.closer.domain.model.MemoryManualInput
import app.closer.domain.model.MemoryRecapEvent
import app.closer.domain.model.MemoryRevealEvent
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
/**
* Pure, deterministic logic for creating [MemoryCapsule] instances from couple events.
*
* No Android dependencies. The generator receives neutral, privacy-safe event metadata
* and produces a capsule with a title, date, category, optional encrypted note, optional
* photo path, and auto-generated tags.
*
* Privacy rules enforced here:
* - Decrypted answer text is never read or included in generated output.
* - Reveal-derived titles use only category metadata and the fact that a reveal happened.
* - Manual notes are accepted as already-encrypted ciphertext; this object does not encrypt.
* - Tags are built from category, source type, and date context only.
*/
object MemoryCapsuleGenerator {
/**
* Default category for a memory when no specific category is supplied.
*/
internal const val DEFAULT_CATEGORY = "memory"
/**
* Category key for a saved date idea that becomes a memory.
*/
internal const val DATE_CATEGORY = "date"
/**
* Category key for a reveal that becomes a memory.
*/
internal const val REVEAL_CATEGORY = "reveal"
/**
* Category key for a weekly recap that becomes a memory.
*/
internal const val RECAP_CATEGORY = "recap"
/**
* Category key for a completed challenge that becomes a memory.
*/
internal const val CHALLENGE_CATEGORY = "challenge"
/**
* Creates a memory capsule from a saved date idea after the date has happened.
*
* @param coupleId couple that owns the memory
* @param authorId user creating the memory
* @param event the saved date idea event
* @param noteCiphertext optional encrypted post-date note
* @param photoStoragePath optional photo path
* @param now current date used when [event.scheduledDate] is absent
*/
fun fromDateIdea(
coupleId: String,
authorId: String,
event: MemoryDateIdeaEvent,
noteCiphertext: String? = null,
photoStoragePath: String? = null,
now: LocalDate = LocalDate.now()
): MemoryCapsule {
val date = event.scheduledDate ?: now
val displayCategory = event.category.takeIf { it.isNotBlank() } ?: DATE_CATEGORY
val title = when {
event.title.isNotBlank() -> "Our ${displayTitleForCategory(displayCategory)} date: ${event.title}"
else -> "Our ${displayTitleForCategory(displayCategory)} date"
}
return MemoryCapsule(
id = "",
coupleId = coupleId,
authorId = authorId,
title = title,
date = date,
category = displayCategory,
noteCiphertext = noteCiphertext,
photoStoragePath = photoStoragePath,
tags = buildTags(
category = displayCategory,
source = MemoryCapsuleSource.DATE_IDEA,
date = date
),
source = MemoryCapsuleSource.DATE_IDEA,
sourceRefId = event.dateIdeaId,
createdAt = System.currentTimeMillis()
)
}
/**
* Creates a memory capsule from a reveal when both partners agree to save it.
*
* The generated title never contains decrypted answer text.
*/
fun fromReveal(
coupleId: String,
authorId: String,
event: MemoryRevealEvent,
noteCiphertext: String? = null,
photoStoragePath: String? = null
): MemoryCapsule {
val displayCategory = event.categoryId?.takeIf { it.isNotBlank() } ?: REVEAL_CATEGORY
val title = "A moment we revealed together${categoryFragment(displayCategory)}"
return MemoryCapsule(
id = "",
coupleId = coupleId,
authorId = authorId,
title = title,
date = event.revealedDate,
category = displayCategory,
noteCiphertext = noteCiphertext,
photoStoragePath = photoStoragePath,
tags = buildTags(
category = displayCategory,
source = MemoryCapsuleSource.REVEAL,
date = event.revealedDate
),
source = MemoryCapsuleSource.REVEAL,
sourceRefId = event.questionId,
createdAt = System.currentTimeMillis()
)
}
/**
* Creates a memory capsule from a completed weekly recap.
*/
fun fromWeeklyRecap(
coupleId: String,
authorId: String,
event: MemoryRecapEvent,
noteCiphertext: String? = null,
photoStoragePath: String? = null
): MemoryCapsule {
val displayCategory = event.favoriteCategory?.takeIf { it.isNotBlank() } ?: RECAP_CATEGORY
val weekNumber = weekNumberLabel(event.weekStart, event.weekEnd)
val title = "Week $weekNumber together${categoryFragment(displayCategory)}"
return MemoryCapsule(
id = "",
coupleId = coupleId,
authorId = authorId,
title = title,
date = event.weekEnd,
category = displayCategory,
noteCiphertext = noteCiphertext,
photoStoragePath = photoStoragePath,
tags = buildTags(
category = displayCategory,
source = MemoryCapsuleSource.WEEKLY_RECAP,
date = event.weekEnd,
extras = weekNumber?.let { listOf("week-$it") } ?: emptyList()
),
source = MemoryCapsuleSource.WEEKLY_RECAP,
sourceRefId = event.recapId,
createdAt = System.currentTimeMillis()
)
}
/**
* Creates a memory capsule from a completed challenge.
*/
fun fromChallenge(
coupleId: String,
authorId: String,
event: MemoryChallengeEvent,
noteCiphertext: String? = null,
photoStoragePath: String? = null
): MemoryCapsule {
val displayCategory = event.challengeCategory?.takeIf { it.isNotBlank() } ?: CHALLENGE_CATEGORY
val title = "Challenge completed${categoryFragment(displayCategory)}"
return MemoryCapsule(
id = "",
coupleId = coupleId,
authorId = authorId,
title = title,
date = event.completedDate,
category = displayCategory,
noteCiphertext = noteCiphertext,
photoStoragePath = photoStoragePath,
tags = buildTags(
category = displayCategory,
source = MemoryCapsuleSource.CHALLENGE,
date = event.completedDate
),
source = MemoryCapsuleSource.CHALLENGE,
sourceRefId = event.challengeId,
createdAt = System.currentTimeMillis()
)
}
/**
* Creates a memory capsule from manual user input.
*
* The caller is responsible for encrypting [input.noteCiphertext] before invoking
* this generator. This object never sees plaintext notes.
*/
fun fromManualInput(
coupleId: String,
authorId: String,
input: MemoryManualInput,
now: LocalDate = LocalDate.now()
): MemoryCapsule {
val displayCategory = input.category.takeIf { it.isNotBlank() } ?: DEFAULT_CATEGORY
val title = input.title.takeIf { it.isNotBlank() }
?: "A moment from ${input.date.format(DateTimeFormatter.ofPattern("MMMM d"))}"
return MemoryCapsule(
id = "",
coupleId = coupleId,
authorId = authorId,
title = title,
date = input.date,
category = displayCategory,
noteCiphertext = input.noteCiphertext,
photoStoragePath = input.photoStoragePath,
tags = buildTags(
category = displayCategory,
source = MemoryCapsuleSource.MANUAL,
date = input.date,
now = now
),
source = MemoryCapsuleSource.MANUAL,
createdAt = System.currentTimeMillis()
)
}
/**
* Builds tags from safe context metadata.
*
* Tags are lowercase, hyphenated, and never contain user text.
*/
internal fun buildTags(
category: String,
source: MemoryCapsuleSource,
date: LocalDate,
extras: List<String> = emptyList(),
now: LocalDate = LocalDate.now()
): List<String> {
val tags = mutableListOf<String>()
val normalizedCategory = category.lowercase().replace(" ", "-")
if (normalizedCategory.isNotBlank()) {
tags.add(normalizedCategory)
}
tags.add(source.toFirestoreValue().replace("_", "-"))
val monthTag = date.month.name.lowercase()
tags.add(monthTag)
val seasonTag = seasonForMonth(date.monthValue)
tags.add(seasonTag)
if (date.year == now.year) {
tags.add("this-year")
}
val dayTag = date.dayOfWeek.name.lowercase()
tags.add(dayTag)
extras.forEach { extra ->
val normalized = extra.lowercase().replace(" ", "-")
if (normalized.isNotBlank() && normalized !in tags) {
tags.add(normalized)
}
}
return tags.distinct()
}
/**
* Returns a human-readable category fragment for titles, e.g. " in Intimacy".
*
* Falls back to empty string for generic categories so titles stay warm and short.
*/
internal fun categoryFragment(category: String): String {
if (category.isBlank()) return ""
val normalized = category.lowercase()
if (normalized in setOf("date", "reveal", "recap", "challenge", "memory")) return ""
val display = displayTitleForCategory(normalized)
return " in $display"
}
/**
* Built-in display names for known category keys.
*/
private val CATEGORY_DISPLAY_NAMES: Map<String, String> = mapOf(
"adventure" to "Adventure",
"travel" to "Travel",
"food" to "Food",
"learning" to "Learning",
"romance" to "Romance",
"intimacy" to "Intimacy",
"connection" to "Connection",
"seasonal" to "Seasonal",
"play" to "Play",
"date" to "Date",
"reveal" to "Reveal",
"recap" to "Recap",
"challenge" to "Challenge",
"memory" to "Memory"
)
/**
* Returns a friendly display word for a category in a title.
*/
internal fun displayTitleForCategory(category: String): String {
val normalized = category.lowercase()
return CATEGORY_DISPLAY_NAMES[normalized]
?: normalized.replaceFirstChar { it.uppercase() }
}
/**
* Returns a week number label from a recap's week start/end.
*
* Weeks are numbered from the start of the year. Returns null when inputs are missing.
*/
internal fun weekNumberLabel(weekStart: LocalDate?, weekEnd: LocalDate?): String? {
if (weekStart == null || weekEnd == null) return null
val isoWeek = weekEnd.get(java.time.temporal.WeekFields.ISO.weekOfWeekBasedYear())
return isoWeek.toString()
}
/**
* Returns a season tag for a month number (1-12).
*/
internal fun seasonForMonth(month: Int): String = when (month) {
12, 1, 2 -> "winter"
3, 4, 5 -> "spring"
6, 7, 8 -> "summer"
9, 10, 11 -> "fall"
else -> "unknown"
}
}

View File

@ -0,0 +1,433 @@
package app.closer.domain
import app.closer.domain.model.MemoryCapsuleSource
import app.closer.domain.model.MemoryChallengeEvent
import app.closer.domain.model.MemoryDateIdeaEvent
import app.closer.domain.model.MemoryManualInput
import app.closer.domain.model.MemoryRecapEvent
import app.closer.domain.model.MemoryRevealEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
class MemoryCapsuleGeneratorTest {
private val coupleId = "couple_1"
private val authorId = "user_1"
private val today: LocalDate = LocalDate.of(2026, 6, 19)
// -----------------------------------------------------------------
// Date idea → memory
// -----------------------------------------------------------------
@Test
fun `date idea produces memory with category title`() {
val event = MemoryDateIdeaEvent(
dateIdeaId = "idea_1",
title = "Pasta night at home",
category = "food",
scheduledDate = today
)
val capsule = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today)
assertEquals(coupleId, capsule.coupleId)
assertEquals(authorId, capsule.authorId)
assertEquals("Our Food date: Pasta night at home", capsule.title)
assertEquals(today, capsule.date)
assertEquals("food", capsule.category)
assertEquals(MemoryCapsuleSource.DATE_IDEA, capsule.source)
assertEquals("idea_1", capsule.sourceRefId)
assertTrue(capsule.tags.contains("food"))
assertTrue(capsule.tags.contains("date-idea"))
assertNull(capsule.noteCiphertext)
assertNull(capsule.photoStoragePath)
}
@Test
fun `date idea without scheduled date uses now`() {
val event = MemoryDateIdeaEvent(
dateIdeaId = "idea_2",
title = "Sunset hike",
category = "adventure"
)
val capsule = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today)
assertEquals(today, capsule.date)
assertTrue(capsule.tags.contains("adventure"))
}
@Test
fun `date idea without category defaults to date category`() {
val event = MemoryDateIdeaEvent(dateIdeaId = "idea_3", title = "Movie night")
val capsule = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today)
assertEquals("date", capsule.category)
assertEquals("Our Date date: Movie night", capsule.title)
}
@Test
fun `date idea accepts encrypted note and photo path`() {
val event = MemoryDateIdeaEvent(
dateIdeaId = "idea_4",
title = "Farmers market brunch",
category = "food",
scheduledDate = today
)
val capsule = MemoryCapsuleGenerator.fromDateIdea(
coupleId,
authorId,
event,
noteCiphertext = "enc:v1:abc123",
photoStoragePath = "couples/couple_1/memories/photo_1.jpg",
now = today
)
assertEquals("enc:v1:abc123", capsule.noteCiphertext)
assertEquals("couples/couple_1/memories/photo_1.jpg", capsule.photoStoragePath)
}
// -----------------------------------------------------------------
// Reveal → memory
// -----------------------------------------------------------------
@Test
fun `reveal produces memory without answer text in title`() {
val event = MemoryRevealEvent(
questionId = "q_1",
categoryId = "intimacy",
revealedDate = today
)
val capsule = MemoryCapsuleGenerator.fromReveal(coupleId, authorId, event)
assertEquals("A moment we revealed together in Intimacy", capsule.title)
assertEquals("intimacy", capsule.category)
assertEquals(today, capsule.date)
assertEquals(MemoryCapsuleSource.REVEAL, capsule.source)
assertEquals("q_1", capsule.sourceRefId)
assertFalse(capsule.title.contains("secret"))
assertFalse(capsule.title.contains("password"))
assertFalse(capsule.tags.any { it.contains("secret") })
}
@Test
fun `reveal without category uses reveal category`() {
val event = MemoryRevealEvent(
questionId = "q_2",
revealedDate = today
)
val capsule = MemoryCapsuleGenerator.fromReveal(coupleId, authorId, event)
assertEquals("reveal", capsule.category)
assertEquals("A moment we revealed together", capsule.title)
}
// -----------------------------------------------------------------
// Weekly recap → memory
// -----------------------------------------------------------------
@Test
fun `weekly recap produces memory with week number`() {
val weekStart = LocalDate.of(2026, 6, 14)
val weekEnd = weekStart.plusDays(6)
val event = MemoryRecapEvent(
recapId = "recap_1",
weekStart = weekStart,
weekEnd = weekEnd,
favoriteCategory = "play"
)
val capsule = MemoryCapsuleGenerator.fromWeeklyRecap(coupleId, authorId, event)
assertEquals("Week 25 together in Play", capsule.title)
assertEquals("play", capsule.category)
assertEquals(weekEnd, capsule.date)
assertEquals(MemoryCapsuleSource.WEEKLY_RECAP, capsule.source)
assertEquals("recap_1", capsule.sourceRefId)
assertTrue(capsule.tags.contains("play"))
assertTrue(capsule.tags.contains("weekly-recap"))
assertTrue(capsule.tags.contains("week-25"))
}
@Test
fun `weekly recap without favorite category uses recap category`() {
val weekStart = LocalDate.of(2026, 6, 14)
val weekEnd = weekStart.plusDays(6)
val event = MemoryRecapEvent(
recapId = "recap_2",
weekStart = weekStart,
weekEnd = weekEnd
)
val capsule = MemoryCapsuleGenerator.fromWeeklyRecap(coupleId, authorId, event)
assertEquals("recap", capsule.category)
assertEquals("Week 25 together", capsule.title)
}
// -----------------------------------------------------------------
// Challenge → memory
// -----------------------------------------------------------------
@Test
fun `challenge completion produces memory`() {
val event = MemoryChallengeEvent(
challengeId = "challenge_1",
challengeCategory = "connection",
completedDate = today
)
val capsule = MemoryCapsuleGenerator.fromChallenge(coupleId, authorId, event)
assertEquals("Challenge completed in Connection", capsule.title)
assertEquals("connection", capsule.category)
assertEquals(today, capsule.date)
assertEquals(MemoryCapsuleSource.CHALLENGE, capsule.source)
assertEquals("challenge_1", capsule.sourceRefId)
}
@Test
fun `challenge without category uses challenge category`() {
val event = MemoryChallengeEvent(
challengeId = "challenge_2",
completedDate = today
)
val capsule = MemoryCapsuleGenerator.fromChallenge(coupleId, authorId, event)
assertEquals("challenge", capsule.category)
assertEquals("Challenge completed", capsule.title)
}
// -----------------------------------------------------------------
// Manual memory
// -----------------------------------------------------------------
@Test
fun `manual input produces memory with encrypted note`() {
val input = MemoryManualInput(
title = "Our first road trip",
date = today,
category = "travel",
noteCiphertext = "enc:v1:note_secret",
photoStoragePath = "photos/roadtrip.jpg"
)
val capsule = MemoryCapsuleGenerator.fromManualInput(coupleId, authorId, input, now = today)
assertEquals("Our first road trip", capsule.title)
assertEquals("travel", capsule.category)
assertEquals(today, capsule.date)
assertEquals("enc:v1:note_secret", capsule.noteCiphertext)
assertEquals("photos/roadtrip.jpg", capsule.photoStoragePath)
assertEquals(MemoryCapsuleSource.MANUAL, capsule.source)
assertTrue(capsule.tags.contains("travel"))
assertTrue(capsule.tags.contains("manual"))
}
@Test
fun `manual input with blank title uses fallback date title`() {
val input = MemoryManualInput(
title = "",
date = LocalDate.of(2026, 12, 25),
category = "romance"
)
val capsule = MemoryCapsuleGenerator.fromManualInput(coupleId, authorId, input, now = today)
assertEquals("A moment from December 25", capsule.title)
assertEquals("romance", capsule.category)
}
@Test
fun `manual input with blank category defaults to memory category`() {
val input = MemoryManualInput(
title = "Random note",
date = today,
category = ""
)
val capsule = MemoryCapsuleGenerator.fromManualInput(coupleId, authorId, input, now = today)
assertEquals("memory", capsule.category)
}
// -----------------------------------------------------------------
// Tags
// -----------------------------------------------------------------
@Test
fun `tags include category source month season and weekday`() {
val tags = MemoryCapsuleGenerator.buildTags(
category = "food",
source = MemoryCapsuleSource.DATE_IDEA,
date = today,
now = today
)
assertTrue(tags.contains("food"))
assertTrue(tags.contains("date-idea"))
assertTrue(tags.contains("june"))
assertTrue(tags.contains("summer"))
assertTrue(tags.contains("friday"))
assertTrue(tags.contains("this-year"))
}
@Test
fun `tags are distinct and lowercase`() {
val tags = MemoryCapsuleGenerator.buildTags(
category = "food",
source = MemoryCapsuleSource.MANUAL,
date = today,
extras = listOf("Food", "food", "manual"),
now = today
)
assertEquals(tags.size, tags.toSet().size)
assertTrue(tags.all { it == it.lowercase() })
}
@Test
fun `tags handle multi word category`() {
val tags = MemoryCapsuleGenerator.buildTags(
category = "road trip",
source = MemoryCapsuleSource.MANUAL,
date = today,
now = today
)
assertTrue(tags.contains("road-trip"))
}
@Test
fun `extras are normalized and deduplicated`() {
val tags = MemoryCapsuleGenerator.buildTags(
category = "food",
source = MemoryCapsuleSource.DATE_IDEA,
date = today,
extras = listOf("Week 25", "week-25", "Food"),
now = today
)
assertEquals(1, tags.count { it == "week-25" })
assertTrue(tags.contains("week-25"))
assertFalse(tags.contains("week 25"))
assertFalse(tags.contains("Food"))
}
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
@Test
fun `categoryFragment omits generic categories`() {
assertEquals("", MemoryCapsuleGenerator.categoryFragment("date"))
assertEquals("", MemoryCapsuleGenerator.categoryFragment("reveal"))
assertEquals("", MemoryCapsuleGenerator.categoryFragment("recap"))
assertEquals("", MemoryCapsuleGenerator.categoryFragment("challenge"))
assertEquals("", MemoryCapsuleGenerator.categoryFragment("memory"))
assertEquals(" in Romance", MemoryCapsuleGenerator.categoryFragment("romance"))
}
@Test
fun `displayTitleForCategory returns known category display name`() {
assertEquals("Adventure", MemoryCapsuleGenerator.displayTitleForCategory("adventure"))
assertEquals("Intimacy", MemoryCapsuleGenerator.displayTitleForCategory("intimacy"))
assertEquals("Custom", MemoryCapsuleGenerator.displayTitleForCategory("custom"))
}
@Test
fun `weekNumberLabel returns ISO week number`() {
val weekStart = LocalDate.of(2026, 6, 14)
val weekEnd = weekStart.plusDays(6)
assertEquals("25", MemoryCapsuleGenerator.weekNumberLabel(weekStart, weekEnd))
}
@Test
fun `weekNumberLabel returns null when inputs are null`() {
assertNull(MemoryCapsuleGenerator.weekNumberLabel(null, today))
assertNull(MemoryCapsuleGenerator.weekNumberLabel(today, null))
}
@Test
fun `seasonForMonth returns correct season`() {
assertEquals("winter", MemoryCapsuleGenerator.seasonForMonth(12))
assertEquals("winter", MemoryCapsuleGenerator.seasonForMonth(1))
assertEquals("spring", MemoryCapsuleGenerator.seasonForMonth(4))
assertEquals("summer", MemoryCapsuleGenerator.seasonForMonth(6))
assertEquals("fall", MemoryCapsuleGenerator.seasonForMonth(10))
}
// -----------------------------------------------------------------
// Privacy
// -----------------------------------------------------------------
@Test
fun `generated titles never contain decrypted answer text`() {
val revealEvent = MemoryRevealEvent(
questionId = "q_secret",
categoryId = "intimacy",
revealedDate = today
)
val revealCapsule = MemoryCapsuleGenerator.fromReveal(coupleId, authorId, revealEvent)
val recapEvent = MemoryRecapEvent(
recapId = "recap_secret",
weekStart = today.minusDays(6),
weekEnd = today,
favoriteCategory = "intimacy"
)
val recapCapsule = MemoryCapsuleGenerator.fromWeeklyRecap(coupleId, authorId, recapEvent)
val sensitiveValues = listOf("I love you", "my secret", "private answer", "plaintext")
listOf(revealCapsule, recapCapsule).forEach { capsule ->
sensitiveValues.forEach { value ->
assertFalse(capsule.title.contains(value))
assertFalse(capsule.tags.any { it.contains(value.lowercase().replace(" ", "-")) })
}
}
}
@Test
fun `manual input generator does not mutate encrypted note`() {
val input = MemoryManualInput(
title = "Private thought",
date = today,
category = "intimacy",
noteCiphertext = "enc:v1:do-not-read"
)
val capsule = MemoryCapsuleGenerator.fromManualInput(coupleId, authorId, input, now = today)
assertEquals("enc:v1:do-not-read", capsule.noteCiphertext)
}
// -----------------------------------------------------------------
// Determinism
// -----------------------------------------------------------------
@Test
fun `same input produces identical capsules`() {
val event = MemoryDateIdeaEvent(
dateIdeaId = "idea_5",
title = "Board game cafe",
category = "play",
scheduledDate = today
)
val first = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today)
val second = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today)
assertEquals(first, second)
}
}