fix(content): give packs a pack_id, and stop icon_name leaking into the UI

Populates question.pack_id (build_db.py). The column was plumbed end to end
(question.pack_id -> QuestionEntity.packId -> Question.packId) but was NULL for
all 3811 rows, so content had no pack identity. Bundled packs now carry one —
the logical id a pack declares in metadata (daily ships as category
daily_fun_mc but is logically daily_single_choice_weekly_v1), else its category
id. This is the hook a purchased/imported pack needs: it becomes just another
pack_id in the same table rather than a special case. No schema or behaviour
change; identity hash unchanged.

Fixes two regressions the rebuilt db exposed, both latent for the same reason:
every category previously carried the placeholder icon_name "question", which
masked them. Packs now declare real Material icon names (shield, forum, paid).

1. Category glyphs. categoryGlyphStyle keyed on `iconName ?: categoryId`, so
   icon_name won. Material names are a different vocabulary from this file's
   keys, so 22 of 23 categories fell through to the default star. The curated
   per-category glyph now wins, with icon_name as the fallback — which is also
   what gives an imported pack a real glyph, since its category_id will not be
   listed here but its declared icon_name resolves. Added the Material aliases
   and the two unmapped categories (quality_time, daily_fun_mc).
2. Pack library chips. metadataLabels() rendered the raw icon_name as a
   user-facing tag, producing chips like "Chat Bubble Outline". It was only ever
   invisible because the list filtered the single value "Question". icon_name is
   a rendering detail, not a topic, so the chip is gone; access remains.

Verified live on both fixtures (dark 5554 / light 5556): distinct correct glyphs
(Boundaries warning, Communication chat, Rebuilding Trust shield, Sex & Desire
heart-outline), no leaked chips, and the new Quality Time pack browsable with
150 questions and a calendar glyph. Unit tests green, 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-14 19:10:37 -05:00
parent 8e6dabd4a4
commit 1ac724bcde
4 changed files with 55 additions and 25 deletions

Binary file not shown.

View File

@ -124,14 +124,31 @@ fun categoryGlyphStyle(
) )
} }
val key = (iconName ?: categoryId).lowercase().trim() // The curated per-category glyph wins; a pack's declared icon_name is the fallback.
return when (key) { // Packs ship Material icon names ("shield", "paid", "forum") — a different vocabulary from the
"communication", "chat", "question", "questions" -> GlyphStyle( // keys below — so letting icon_name take precedence sent almost every category to the default
// star. Keeping icon_name as a fallback is what gives an imported/purchased pack a real glyph:
// its category_id won't be listed here, so its declared icon_name resolves instead.
return glyphStyleForKey(categoryId.lowercase().trim())
?: iconName?.let { glyphStyleForKey(it.lowercase().trim()) }
?: GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_star),
tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist
)
}
/** Resolves one key (a category id, or a pack's icon_name) to a glyph, or null if unknown. */
@Composable
private fun glyphStyleForKey(key: String): GlyphStyle? =
when (key) {
"communication", "chat", "question", "questions",
"chat_bubble_outline", "forum" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_chat), icon = ImageVector.vectorResource(R.drawable.glyph_chat),
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist container = CloserPalette.PurpleMist
) )
"emotional_intimacy", "intimacy", "heart" -> GlyphStyle( "emotional_intimacy", "intimacy", "heart", "favorite", "volunteer_activism" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_heart), icon = ImageVector.vectorResource(R.drawable.glyph_heart),
tint = CloserPalette.PinkAccentDeep, tint = CloserPalette.PinkAccentDeep,
container = CloserPalette.PinkMist container = CloserPalette.PinkMist
@ -141,22 +158,22 @@ fun categoryGlyphStyle(
tint = CloserPalette.Romantic, tint = CloserPalette.Romantic,
container = CloserPalette.Romantic.copy(alpha = 0.12f) container = CloserPalette.Romantic.copy(alpha = 0.12f)
) )
"trust", "rebuilding_trust", "privacy" -> GlyphStyle( "trust", "rebuilding_trust", "privacy", "shield", "verified_user" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_shield), icon = ImageVector.vectorResource(R.drawable.glyph_shield),
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleGlow container = CloserPalette.PurpleGlow
) )
"conflict", "conflict_repair", "repair" -> GlyphStyle( "conflict", "conflict_repair", "repair", "healing" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_sync), icon = ImageVector.vectorResource(R.drawable.glyph_sync),
tint = CloserPalette.PurpleRich, tint = CloserPalette.PurpleRich,
container = CloserPalette.PurpleMist container = CloserPalette.PurpleMist
) )
"future", "timeline" -> GlyphStyle( "future", "timeline", "explore" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_timeline), icon = ImageVector.vectorResource(R.drawable.glyph_timeline),
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleSoft container = CloserPalette.PurpleSoft
) )
"money", "payments" -> GlyphStyle( "money", "payments", "paid" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_attach_money), icon = ImageVector.vectorResource(R.drawable.glyph_attach_money),
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist container = CloserPalette.PurpleMist
@ -166,22 +183,23 @@ fun categoryGlyphStyle(
tint = CloserPalette.PurpleRich, tint = CloserPalette.PurpleRich,
container = CloserPalette.PurpleGlow container = CloserPalette.PurpleGlow
) )
"gratitude", "star" -> GlyphStyle( "gratitude", "star", "celebration" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_star), icon = ImageVector.vectorResource(R.drawable.glyph_star),
tint = CloserPalette.PinkAccentDeep, tint = CloserPalette.PinkAccentDeep,
container = CloserPalette.PinkMist container = CloserPalette.PinkMist
) )
"parenting", "family", "people" -> GlyphStyle( "parenting", "family", "people", "family_restroom" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_couple), icon = ImageVector.vectorResource(R.drawable.glyph_couple),
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleSoft container = CloserPalette.PurpleSoft
) )
"date_night", "date", "calendar" -> GlyphStyle( "date_night", "date", "calendar", "calendar_heart",
"quality_time", "schedule_heart" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_calendar), icon = ImageVector.vectorResource(R.drawable.glyph_calendar),
tint = CloserPalette.PinkAccentDeep, tint = CloserPalette.PinkAccentDeep,
container = CloserPalette.PinkMist container = CloserPalette.PinkMist
) )
"fun", "play" -> GlyphStyle( "fun", "play", "emoji_emotions" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_play), icon = ImageVector.vectorResource(R.drawable.glyph_play),
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleGlow container = CloserPalette.PurpleGlow
@ -191,7 +209,7 @@ fun categoryGlyphStyle(
tint = CloserPalette.Danger, tint = CloserPalette.Danger,
container = CloserPalette.Danger.copy(alpha = 0.10f) container = CloserPalette.Danger.copy(alpha = 0.10f)
) )
"values", "marriage" -> GlyphStyle( "values", "marriage", "favorite_border" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_question_answer), icon = ImageVector.vectorResource(R.drawable.glyph_question_answer),
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist container = CloserPalette.PurpleMist
@ -206,15 +224,10 @@ fun categoryGlyphStyle(
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleGlow container = CloserPalette.PurpleGlow
) )
"predict", "psychology" -> GlyphStyle( "predict", "psychology", "self_improvement" -> GlyphStyle(
icon = ImageVector.vectorResource(R.drawable.glyph_psychology), icon = ImageVector.vectorResource(R.drawable.glyph_psychology),
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleGlow container = CloserPalette.PurpleGlow
) )
else -> GlyphStyle( else -> null
icon = ImageVector.vectorResource(R.drawable.glyph_star),
tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist
)
}
} }

View File

@ -367,10 +367,11 @@ private fun QuestionPackItem.metadataLabels(): List<String> {
"free" -> "Free" "free" -> "Free"
else -> category.access.displayCategoryName() else -> category.access.displayCategoryName()
} }
return listOf( // Only access is shown. `icon_name` used to be listed here too, but it is a rendering detail,
access, // not a topic: packs declare Material icon names, so it surfaced chips like "Chat Bubble
category.iconName.ifBlank { "question" }.displayCategoryName() // Outline". It went unnoticed because every pack once carried the placeholder icon "question",
).filterNot { it == "Question" }.distinct() // which the list filtered out — so this chip has never actually been visible.
return listOf(access).distinct()
} }
@Composable @Composable

View File

@ -169,6 +169,21 @@ def build_answer_config(question: Dict[str, Any], where: str) -> Dict[str, Any]:
# Validation — every problem is fatal # Validation — every problem is fatal
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def pack_id_for(category: Dict[str, Any]) -> str:
"""The pack a question belongs to.
`question.pack_id` is plumbed all the way to the domain model and is what lets content be
handled per-pack (query it, gate it, remove it) rather than only per-category. Bundled packs
get one too, so a later purchased/imported pack is not a special case it is just another
pack_id in the same table.
Prefer the logical pack id a pack declares in its metadata (the daily pack ships as category
`daily_fun_mc` but is logically `daily_single_choice_weekly_v1`); otherwise the category id.
"""
meta = category.get("metadata") or {}
return meta.get("pack_id") or category["id"]
def load_packs() -> List[Dict[str, Any]]: def load_packs() -> List[Dict[str, Any]]:
"""Load and hard-validate every pack. Raises on the first structural problem.""" """Load and hard-validate every pack. Raises on the first structural problem."""
files = sorted(JSON_DIR.glob("*.json")) files = sorted(JSON_DIR.glob("*.json"))
@ -255,6 +270,7 @@ def build(packs: List[Dict[str, Any]], db_schema: Dict[str, Any], out: Path) ->
total = 0 total = 0
for pack in packs: for pack in packs:
c = pack["category"] c = pack["category"]
pack_id = pack_id_for(c)
cur.execute( cur.execute(
"INSERT OR REPLACE INTO question_category " "INSERT OR REPLACE INTO question_category "
"(id, display_name, description, access, icon_name) VALUES (?, ?, ?, ?, ?)", "(id, display_name, description, access, icon_name) VALUES (?, ?, ?, ?, ?)",
@ -276,7 +292,7 @@ def build(packs: List[Dict[str, Any]], db_schema: Dict[str, Any], out: Path) ->
q["type"], q["type"],
json.dumps(q.get("tags", []), separators=(",", ":")), json.dumps(q.get("tags", []), separators=(",", ":")),
json.dumps(build_answer_config(q, where), separators=(",", ":")), json.dumps(build_answer_config(q, where), separators=(",", ":")),
None, pack_id,
pack["mtime"], pack["mtime"],
"active", "active",
q.get("sex"), q.get("sex"),