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:
parent
f336c91731
commit
f74f0e96f5
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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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"}
|
||||||
|
|
@ -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%)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (4–6 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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue