feat(daily): wildcard-day picker branch + authoring spec (Option A)

The client's DailyModeResolver promotes ~10% of days (dayOfYear % 10 == 3) to a
'Wildcard' theme, but there were zero wildcard questions and the server never
assigned one — so those days showed the Wildcard banner over a normal weekday
question. This adds the server half:

- pickDailyQuestionId now detects wildcard days (new pure, tested dayOfYearUtc /
  isWildcardDay helpers mirroring the client cadence) and prefers the mode_wildcard
  pool. Until that content is seeded it falls back to the day's WEEKDAY mode (not
  the whole pool), so it's a safe no-op pre-content. +3 unit tests (fn 80 -> 83).
- Authoring spec for the missing content added to
  seed/questions/DAILY_SINGLE_CHOICE_WEEKDAY_SYSTEM.md (## Wildcard Mode): 12 free
  single_choice, day-agnostic voice, REQUIRED mode_wildcard tag, id scheme, schema,
  and the post-authoring rollout (asset-db data-only insert + Firestore pointer seed
  + deploy).

Content authoring handed to another agent per the guide. Takes effect after the
wildcard rows land in app.db + Firestore and assignDailyQuestion is deployed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-11 16:39:59 -05:00
parent f336c91731
commit f74f0e96f5
7 changed files with 224 additions and 27 deletions

View File

@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0;
exports.dayOfYearUtc = dayOfYearUtc;
exports.isWildcardDay = isWildcardDay;
exports.formatCstDate = formatCstDate;
exports.parseCstDate = parseCstDate;
exports.timestampAt6PmCst = timestampAt6PmCst;
@ -202,12 +204,26 @@ function epochDayOf(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / 86400000);
}
// The client promotes ~10% of days to a "Wildcard" surprise theme instead of the weekday mode
// (DailyModeResolver.resolve(): dayOfYear % 10 === 3). FROZEN to mirror that tag + cadence.
const WILDCARD_MODE_TAG = 'mode_wildcard';
/** 1-based day of the year for a YYYY-MM-DD date (UTC) matches Java's Calendar.DAY_OF_YEAR,
* which the client's DailyModeResolver uses for its wildcard cadence. */
function dayOfYearUtc(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number);
return Math.floor((Date.UTC(y, m - 1, d) - Date.UTC(y, 0, 1)) / 86400000) + 1;
}
/** Mirrors the client's DailyModeResolver ~10% wildcard override (dayOfYear % 10 === 3). */
function isWildcardDay(dateStr) {
return dayOfYearUtc(dateStr) % 10 === 3;
}
/**
* Deterministically pick the SAME daily question the free client would compute for [dateStr]:
* today's weekday mode, ordered by id, indexed by epochDay % poolSize. Making this SERVER-side and
* authoritative also fixes partners in different time zones getting different questions (the client
* falls back to device-local weekday only when there is no server assignment). Returns null if the
* questions pool is unseeded so the caller can no-op rather than write an unresolvable id.
* today's mode (weekday, or wildcard on ~10% of days), ordered by id, indexed by epochDay %
* poolSize. Making this SERVER-side and authoritative also fixes partners in different time zones
* getting different questions (the client falls back to device-local weekday only when there is no
* server assignment). Returns null if the questions pool is unseeded so the caller can no-op rather
* than write an unresolvable id.
*/
async function pickDailyQuestionId(dateStr) {
const db = admin.firestore();
@ -220,14 +236,16 @@ async function pickDailyQuestionId(dateStr) {
.get();
if (snapshot.empty)
return null;
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay();
const modeTag = WEEKDAY_MODE_TAGS[weekday];
const inMode = snapshot.docs
.filter((d) => d.get('modeTag') === modeTag)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
const pool = inMode.length > 0
? inMode
: snapshot.docs.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); // wildcard/empty-mode fallback
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
const inTag = (tag) => snapshot.docs.filter((d) => d.get('modeTag') === tag).sort(byId);
const weekdayTag = WEEKDAY_MODE_TAGS[new Date(`${dateStr}T12:00:00Z`).getUTCDay()];
// On a wildcard day prefer the wildcard pool; if it isn't seeded yet, fall back to today's
// WEEKDAY mode (not the whole pool), so this stays a safe no-op until wildcard content lands.
let pool = isWildcardDay(dateStr) ? inTag(WILDCARD_MODE_TAG) : [];
if (pool.length === 0)
pool = inTag(weekdayTag);
if (pool.length === 0)
pool = snapshot.docs.slice().sort(byId); // last-resort: mode unseeded
const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length;
return pool[offset].id;
}

File diff suppressed because one or more lines are too long

View File

@ -35,4 +35,32 @@ describe('Chicago date helpers (DST-safe)', () => {
}
});
});
// Wildcard cadence must mirror the client's DailyModeResolver (dayOfYear % 10 === 3), or the
// server would assign a weekday question on days the app themes "Wildcard" (and vice versa).
describe('wildcard-day cadence (mirrors client DailyModeResolver)', () => {
it('computes 1-based day-of-year like Calendar.DAY_OF_YEAR', () => {
expect((0, assignDailyQuestion_1.dayOfYearUtc)('2026-01-01')).toBe(1);
expect((0, assignDailyQuestion_1.dayOfYearUtc)('2026-01-03')).toBe(3);
expect((0, assignDailyQuestion_1.dayOfYearUtc)('2026-02-01')).toBe(32);
expect((0, assignDailyQuestion_1.dayOfYearUtc)('2026-12-31')).toBe(365); // 2026 is not a leap year
expect((0, assignDailyQuestion_1.dayOfYearUtc)('2028-12-31')).toBe(366); // leap year
});
it('fires wildcard exactly when day-of-year mod 10 === 3', () => {
expect((0, assignDailyQuestion_1.isWildcardDay)('2026-01-03')).toBe(true); // doy 3
expect((0, assignDailyQuestion_1.isWildcardDay)('2026-01-13')).toBe(true); // doy 13
expect((0, assignDailyQuestion_1.isWildcardDay)('2026-02-01')).toBe(false); // doy 32
expect((0, assignDailyQuestion_1.isWildcardDay)('2026-01-01')).toBe(false); // doy 1
expect((0, assignDailyQuestion_1.isWildcardDay)('2026-01-04')).toBe(false); // doy 4
});
it('flags ~10% of the year as wildcard days', () => {
let wild = 0;
for (let doy = 1; doy <= 365; doy++) {
const d = new Date(Date.UTC(2026, 0, doy));
const dateStr = d.toISOString().slice(0, 10);
if ((0, assignDailyQuestion_1.isWildcardDay)(dateStr))
wild++;
}
expect(wild).toBe(36); // doy ∈ {3,13,…,363} → 36 days
});
});
//# sourceMappingURL=assignDailyQuestion.test.js.map

View File

@ -1 +1 @@
{"version":3,"file":"assignDailyQuestion.test.js","sourceRoot":"","sources":["../../src/questions/assignDailyQuestion.test.ts"],"names":[],"mappings":";;AAAA,+DAAsF;AAEtF,iFAAiF;AACjF,uEAAuE;AACvE,QAAQ,CAAC,iCAAiC,EAAE,GAAG,EAAE;IAC/C,EAAE,CAAC,kEAAkE,EAAE,GAAG,EAAE;QAC1E,gFAAgF;QAChF,uEAAuE;QACvE,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;QAC1E,oFAAoF;QACpF,gFAAgF;QAChF,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;IAC5E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD,4CAA4C;QAC5C,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;QAC1E,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;IAC5E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,iCAAiC;QACjC,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;QACjF,iCAAiC;QACjC,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;IACnF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,8BAA8B;QAC9B,MAAM,CAAC,IAAA,uCAAiB,EAAC,YAAY,CAAC,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;QAC/F,uCAAuC;QACvC,MAAM,CAAC,IAAA,uCAAiB,EAAC,YAAY,CAAC,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;IACjG,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,iEAAiE,EAAE,GAAG,EAAE;QACzE,KAAK,MAAM,GAAG,IAAI,CAAC,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,CAAC,EAAE,CAAC;YACzF,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAA,kCAAY,EAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACpD,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
{"version":3,"file":"assignDailyQuestion.test.js","sourceRoot":"","sources":["../../src/questions/assignDailyQuestion.test.ts"],"names":[],"mappings":"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"}

View File

@ -1,4 +1,4 @@
import { formatCstDate, parseCstDate, timestampAt6PmCst } from './assignDailyQuestion'
import { formatCstDate, parseCstDate, timestampAt6PmCst, dayOfYearUtc, isWildcardDay } from './assignDailyQuestion'
// DST regression tests for the Chicago date helpers (the old hardcoded -6 offset
// mislabeled dates and shifted reveal times during the ~8 CDT months).
@ -38,3 +38,33 @@ describe('Chicago date helpers (DST-safe)', () => {
}
})
})
// Wildcard cadence must mirror the client's DailyModeResolver (dayOfYear % 10 === 3), or the
// server would assign a weekday question on days the app themes "Wildcard" (and vice versa).
describe('wildcard-day cadence (mirrors client DailyModeResolver)', () => {
it('computes 1-based day-of-year like Calendar.DAY_OF_YEAR', () => {
expect(dayOfYearUtc('2026-01-01')).toBe(1)
expect(dayOfYearUtc('2026-01-03')).toBe(3)
expect(dayOfYearUtc('2026-02-01')).toBe(32)
expect(dayOfYearUtc('2026-12-31')).toBe(365) // 2026 is not a leap year
expect(dayOfYearUtc('2028-12-31')).toBe(366) // leap year
})
it('fires wildcard exactly when day-of-year mod 10 === 3', () => {
expect(isWildcardDay('2026-01-03')).toBe(true) // doy 3
expect(isWildcardDay('2026-01-13')).toBe(true) // doy 13
expect(isWildcardDay('2026-02-01')).toBe(false) // doy 32
expect(isWildcardDay('2026-01-01')).toBe(false) // doy 1
expect(isWildcardDay('2026-01-04')).toBe(false) // doy 4
})
it('flags ~10% of the year as wildcard days', () => {
let wild = 0
for (let doy = 1; doy <= 365; doy++) {
const d = new Date(Date.UTC(2026, 0, doy))
const dateStr = d.toISOString().slice(0, 10)
if (isWildcardDay(dateStr)) wild++
}
expect(wild).toBe(37) // doy ∈ {3,13,…,363} → 37 days (~10.1%)
})
})

View File

@ -177,12 +177,29 @@ function epochDayOf(dateStr: string): number {
return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000)
}
// The client promotes ~10% of days to a "Wildcard" surprise theme instead of the weekday mode
// (DailyModeResolver.resolve(): dayOfYear % 10 === 3). FROZEN to mirror that tag + cadence.
const WILDCARD_MODE_TAG = 'mode_wildcard'
/** 1-based day of the year for a YYYY-MM-DD date (UTC) matches Java's Calendar.DAY_OF_YEAR,
* which the client's DailyModeResolver uses for its wildcard cadence. */
export function dayOfYearUtc(dateStr: string): number {
const [y, m, d] = dateStr.split('-').map(Number)
return Math.floor((Date.UTC(y, m - 1, d) - Date.UTC(y, 0, 1)) / 86_400_000) + 1
}
/** Mirrors the client's DailyModeResolver ~10% wildcard override (dayOfYear % 10 === 3). */
export function isWildcardDay(dateStr: string): boolean {
return dayOfYearUtc(dateStr) % 10 === 3
}
/**
* Deterministically pick the SAME daily question the free client would compute for [dateStr]:
* today's weekday mode, ordered by id, indexed by epochDay % poolSize. Making this SERVER-side and
* authoritative also fixes partners in different time zones getting different questions (the client
* falls back to device-local weekday only when there is no server assignment). Returns null if the
* questions pool is unseeded so the caller can no-op rather than write an unresolvable id.
* today's mode (weekday, or wildcard on ~10% of days), ordered by id, indexed by epochDay %
* poolSize. Making this SERVER-side and authoritative also fixes partners in different time zones
* getting different questions (the client falls back to device-local weekday only when there is no
* server assignment). Returns null if the questions pool is unseeded so the caller can no-op rather
* than write an unresolvable id.
*/
async function pickDailyQuestionId(dateStr: string): Promise<string | null> {
const db = admin.firestore()
@ -195,14 +212,16 @@ async function pickDailyQuestionId(dateStr: string): Promise<string | null> {
.get()
if (snapshot.empty) return null
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay()
const modeTag = WEEKDAY_MODE_TAGS[weekday]
const inMode = snapshot.docs
.filter((d) => d.get('modeTag') === modeTag)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const pool = inMode.length > 0
? inMode
: snapshot.docs.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) // wildcard/empty-mode fallback
type Doc = FirebaseFirestore.QueryDocumentSnapshot
const byId = (a: Doc, b: Doc): number => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
const inTag = (tag: string): Doc[] => snapshot.docs.filter((d) => d.get('modeTag') === tag).sort(byId)
const weekdayTag = WEEKDAY_MODE_TAGS[new Date(`${dateStr}T12:00:00Z`).getUTCDay()]
// On a wildcard day prefer the wildcard pool; if it isn't seeded yet, fall back to today's
// WEEKDAY mode (not the whole pool), so this stays a safe no-op until wildcard content lands.
let pool: Doc[] = isWildcardDay(dateStr) ? inTag(WILDCARD_MODE_TAG) : []
if (pool.length === 0) pool = inTag(weekdayTag)
if (pool.length === 0) pool = snapshot.docs.slice().sort(byId) // last-resort: mode unseeded
const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length
return pool[offset].id
}

View File

@ -554,6 +554,107 @@ Required tag:
daily_sunday_slow_burn
```
## Wildcard Mode (Surprise Days) — CONTENT NEEDED
Wildcard is **not a weekday**. It is a surprise override that can land on **any** day.
The app already promotes roughly **10% of days** to a "Wildcard" theme (it fires when the
day-of-year mod 10 equals 3 — about 37 days a year). On those days the app shows the header
**"Wildcard — The app has opinions"** with the action line **"Wildcard mission: say yes to one
tiny weird idea."** Today there are **zero** wildcard questions, so those days silently show a
normal weekday question under the Wildcard banner. This section defines the missing content.
### Wildcard voice
Playful, surprising, a little weird, low pressure. The feeling is *the app just picked something
unexpected for the two of you.* Think tiny dares, silly "would you rather", spontaneous micro-plans,
harmless chaos, "say yes to the odd option."
Because a wildcard day can land on **any** weekday, wildcard questions must be **day-agnostic**
never reference Monday/Friday/the weekend or a specific weekday vibe. They must read well no matter
which day they appear on.
Same gates as the weekday packs (see Daily Fun Gate, Option Quality Standard, Banned Tone Words):
quick, warm, PG-13 max on the free tier, 4 options preferred (46 allowed), single choice.
### Wildcard counts
* **12 free** `single_choice` wildcard questions. **Free only** — the daily picker never draws from
the premium pool, so premium wildcards would never be served. Do not author premium wildcards.
* This is **in addition** to the existing 500 (75 free + 425 premium) weekday questions.
### Wildcard IDs
Continue the existing sequence so ids stay sortable and unique — the picker sorts by id:
```text
daily_single_choice_weekly_v1_501 … daily_single_choice_weekly_v1_512
```
### Wildcard tags (EXACT — this is an app contract)
Every wildcard question must carry these tags, and **must NOT** carry any `weekday_*` tag or any
weekday `mode_*` tag (a weekday mode tag would wrongly pull the wildcard into that weekday's pool):
```json
"tags": [
"daily",
"single_choice",
"best_fit",
"mode_wildcard", // REQUIRED — the app selects wildcard questions on this EXACT tag.
"daily_wildcard", // new-scheme weekday-style tag, kept for consistency
"quick_answer"
]
```
> **`mode_wildcard` is non-negotiable.** The client (`DailyModeResolver.MODES["wildcard"].modeTag`)
> and the server both filter on the literal string `mode_wildcard`. A wildcard question without this
> exact tag is invisible to the wildcard picker.
### Wildcard question schema (copy this shape)
```json
{
"id": "daily_single_choice_weekly_v1_501",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "The app dares you: which tiny weird idea do we say yes to tonight?",
"depth": 1,
"access": "free",
"sex": "neutral",
"tags": ["daily", "single_choice", "best_fit", "mode_wildcard", "daily_wildcard", "quick_answer"],
"options": [
{ "id": "swap_one_chore_for_a_dance", "text": "Swap one chore for a 2-song dance break" },
{ "id": "dessert_for_dinner_experiment", "text": "Dessert-for-dinner experiment" },
{ "id": "text_each_other_only_in_emoji", "text": "Only talk in emoji for an hour" },
{ "id": "rename_the_wifi_together", "text": "Rename the wifi together, right now" }
],
"answer_config": {
"options": [
{ "id": "swap_one_chore_for_a_dance", "text": "Swap one chore for a 2-song dance break" },
{ "id": "dessert_for_dinner_experiment", "text": "Dessert-for-dinner experiment" },
{ "id": "text_each_other_only_in_emoji", "text": "Only talk in emoji for an hour" },
{ "id": "rename_the_wifi_together", "text": "Rename the wifi together, right now" }
],
"selection_style": "single"
}
}
```
### After the JSON is authored — engineer rollout (NOT the content agent's job)
Once the 12 wildcard questions are in `daily_fun_multiple_choice_v3.json`, an engineer lands them:
1. **Asset DB:** insert the 12 rows into `app/src/main/assets/database/app.db` `question` table
**data-only** (`category_id='daily_fun_mc'`, `status='active'`, `is_premium=0`, `tags` containing
`mode_wildcard`). **Do NOT run `build_db.py`** — a full rebuild changes the Room identity hash and
breaks the shipped DB (see project notes). Data-only inserts are safe.
2. **Firestore pointer docs:** seed one `questions/{id}` doc per wildcard question:
`{ questionId, modeTag: "mode_wildcard", active: true, isPremium: false, type: "single_choice",
source: "asset_db_daily_fun_mc", seededAt }`.
3. **Deploy** the updated `assignDailyQuestion` function (the `mode_wildcard` picker branch is already
in code; until wildcard rows exist it safely falls back to the day's weekday question).
## Required Tags
Each question must include exactly one new weekday tag:
@ -565,6 +666,7 @@ Each question must include exactly one new weekday tag:
* daily_friday_flirty
* daily_saturday_side_quest
* daily_sunday_slow_burn
* daily_wildcard (wildcard surprise days — see Wildcard Mode above; pairs with the REQUIRED `mode_wildcard` app tag)
If the app code still uses older mode tags, include the compatibility tag too, but only one new weekday tag.