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 }); Object.defineProperty(exports, "__esModule", { value: true });
exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0; exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0;
exports.dayOfYearUtc = dayOfYearUtc;
exports.isWildcardDay = isWildcardDay;
exports.formatCstDate = formatCstDate; exports.formatCstDate = formatCstDate;
exports.parseCstDate = parseCstDate; exports.parseCstDate = parseCstDate;
exports.timestampAt6PmCst = timestampAt6PmCst; exports.timestampAt6PmCst = timestampAt6PmCst;
@ -202,12 +204,26 @@ function epochDayOf(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number); const [y, m, d] = dateStr.split('-').map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / 86400000); 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]: * 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 * today's mode (weekday, or wildcard on ~10% of days), ordered by id, indexed by epochDay %
* authoritative also fixes partners in different time zones getting different questions (the client * poolSize. Making this SERVER-side and authoritative also fixes partners in different time zones
* falls back to device-local weekday only when there is no server assignment). Returns null if the * getting different questions (the client falls back to device-local weekday only when there is no
* questions pool is unseeded so the caller can no-op rather than write an unresolvable id. * 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) { async function pickDailyQuestionId(dateStr) {
const db = admin.firestore(); const db = admin.firestore();
@ -220,14 +236,16 @@ async function pickDailyQuestionId(dateStr) {
.get(); .get();
if (snapshot.empty) if (snapshot.empty)
return null; return null;
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay(); const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
const modeTag = WEEKDAY_MODE_TAGS[weekday]; const inTag = (tag) => snapshot.docs.filter((d) => d.get('modeTag') === tag).sort(byId);
const inMode = snapshot.docs const weekdayTag = WEEKDAY_MODE_TAGS[new Date(`${dateStr}T12:00:00Z`).getUTCDay()];
.filter((d) => d.get('modeTag') === modeTag) // On a wildcard day prefer the wildcard pool; if it isn't seeded yet, fall back to today's
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); // WEEKDAY mode (not the whole pool), so this stays a safe no-op until wildcard content lands.
const pool = inMode.length > 0 let pool = isWildcardDay(dateStr) ? inTag(WILDCARD_MODE_TAG) : [];
? inMode if (pool.length === 0)
: snapshot.docs.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); // wildcard/empty-mode fallback 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; const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length;
return pool[offset].id; 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 //# 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 // DST regression tests for the Chicago date helpers (the old hardcoded -6 offset
// mislabeled dates and shifted reveal times during the ~8 CDT months). // 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) 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]: * 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 * today's mode (weekday, or wildcard on ~10% of days), ordered by id, indexed by epochDay %
* authoritative also fixes partners in different time zones getting different questions (the client * poolSize. Making this SERVER-side and authoritative also fixes partners in different time zones
* falls back to device-local weekday only when there is no server assignment). Returns null if the * getting different questions (the client falls back to device-local weekday only when there is no
* questions pool is unseeded so the caller can no-op rather than write an unresolvable id. * 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> { async function pickDailyQuestionId(dateStr: string): Promise<string | null> {
const db = admin.firestore() const db = admin.firestore()
@ -195,14 +212,16 @@ async function pickDailyQuestionId(dateStr: string): Promise<string | null> {
.get() .get()
if (snapshot.empty) return null if (snapshot.empty) return null
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay() type Doc = FirebaseFirestore.QueryDocumentSnapshot
const modeTag = WEEKDAY_MODE_TAGS[weekday] const byId = (a: Doc, b: Doc): number => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
const inMode = snapshot.docs const inTag = (tag: string): Doc[] => snapshot.docs.filter((d) => d.get('modeTag') === tag).sort(byId)
.filter((d) => d.get('modeTag') === modeTag)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) const weekdayTag = WEEKDAY_MODE_TAGS[new Date(`${dateStr}T12:00:00Z`).getUTCDay()]
const pool = inMode.length > 0 // On a wildcard day prefer the wildcard pool; if it isn't seeded yet, fall back to today's
? inMode // WEEKDAY mode (not the whole pool), so this stays a safe no-op until wildcard content lands.
: snapshot.docs.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) // wildcard/empty-mode fallback 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 const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length
return pool[offset].id return pool[offset].id
} }

View File

@ -554,6 +554,107 @@ Required tag:
daily_sunday_slow_burn 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 ## Required Tags
Each question must include exactly one new weekday tag: 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_friday_flirty
* daily_saturday_side_quest * daily_saturday_side_quest
* daily_sunday_slow_burn * 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. If the app code still uses older mode tags, include the compatibility tag too, but only one new weekday tag.