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 });
|
||||
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
|
|
@ -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
|
||||
|
|
@ -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":";;AAAA,+DAAmH;AAEnH,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;AAEF,6FAA6F;AAC7F,6FAA6F;AAC7F,QAAQ,CAAC,yDAAyD,EAAE,GAAG,EAAE;IACvE,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAChE,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAC1C,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAC1C,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QAC3C,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA,CAAC,0BAA0B;QACvE,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA,CAAC,YAAY;IAC3D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sDAAsD,EAAE,GAAG,EAAE;QAC9D,MAAM,CAAC,IAAA,mCAAa,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA,CAAC,QAAQ;QACvD,MAAM,CAAC,IAAA,mCAAa,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA,CAAC,SAAS;QACxD,MAAM,CAAC,IAAA,mCAAa,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA,CAAC,SAAS;QACzD,MAAM,CAAC,IAAA,mCAAa,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA,CAAC,QAAQ;QACxD,MAAM,CAAC,IAAA,mCAAa,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA,CAAC,QAAQ;IAC1D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,yCAAyC,EAAE,GAAG,EAAE;QACjD,IAAI,IAAI,GAAG,CAAC,CAAA;QACZ,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,IAAI,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC;YACpC,MAAM,CAAC,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,CAAC,CAAA;YAC1C,MAAM,OAAO,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;YAC5C,IAAI,IAAA,mCAAa,EAAC,OAAO,CAAC;gBAAE,IAAI,EAAE,CAAA;QACpC,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA,CAAC,+BAA+B;IACvD,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
|
||||
|
|
@ -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%)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (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
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue