chore(functions): rebuild dist for deployed daily-question + game-copy changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-07 22:31:02 -05:00
parent 15e4579410
commit 3acba138a3
6 changed files with 139 additions and 38 deletions

View File

@ -157,7 +157,12 @@ exports.onGameSessionUpdate = functions.firestore
return;
}
// ── Session completed (reveal ready for both) ────────────────────────
if (status === 'completed' && !currentData.finishNotifiedAt) {
// Only a REAL completion has results: the async flow marks each user complete at reveal, so
// status flips to 'completed' with both uids in completedByUsers. An abandoned/quit session
// also lands on 'completed' (the abandon write) but with an empty list — pushing
// "finished — see your results!" for those was a false notification into an empty reveal.
const completedBy = Array.isArray(currentData.completedByUsers) ? currentData.completedByUsers : [];
if (status === 'completed' && !currentData.finishNotifiedAt && completedBy.length >= 2) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef);
const d = fresh.data();
@ -240,8 +245,20 @@ exports.onGamePartFinished = functions.firestore
const finisher = await db.collection('users').doc(finisherUid).get();
const finisherName = 'Your partner'; // displayName is E2EE; the app shows the real name in-app
const finisherAvatar = (_e = finisher.data()) === null || _e === void 0 ? void 0 : _e.photoUrl;
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId, finisherAvatar, sessionId);
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', yourTurnBody(gameType), coupleId, finisherAvatar, sessionId);
});
/**
* Per-game body for the "your turn" (partner_completed_part) push mirrors the client banner's
* YOUR_TURN copy in ui/components/GamePromptBanner.kt so foreground and background read the same.
*/
function yourTurnBody(gameType) {
switch (gameType) {
case 'how_well': return 'Your turn — guess their answers';
case 'desire_sync': return 'Your turn — only mutual yeses ever show';
case 'this_or_that': return 'Your turn — see where you line up';
default: return 'Your turn — reveal how you line up';
}
}
/**
* Send notification to partner via FCM and write to notification_queue.
*/

File diff suppressed because one or more lines are too long

View File

@ -34,9 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0;
exports.formatCstDate = formatCstDate;
exports.parseCstDate = parseCstDate;
exports.timestampAt6PmCst = timestampAt6PmCst;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
const CST_OFFSET_HOURS = -6;
/**
* Scheduled function that assigns one daily question per couple every day.
*
@ -57,9 +59,9 @@ exports.assignDailyQuestion = functions.pubsub
const db = admin.firestore();
const today = cstDateString();
const nextDay = nextCstDateString();
const questionId = await pickRandomQuestionId();
const questionId = await pickDailyQuestionId(today);
if (!questionId) {
console.error('[assignDailyQuestion] no active questions available');
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment');
return;
}
const couplesSnap = await db.collection('couples').get();
@ -135,7 +137,7 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
if (date !== today) {
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.');
}
const questionId = await pickRandomQuestionId();
const questionId = await pickDailyQuestionId(today);
if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.');
}
@ -173,20 +175,51 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
* so the app still functions during rollout. In production, keep the local
* Room database and Firestore pool in sync through the existing seed flow.
*/
async function pickRandomQuestionId() {
// Weekday → daily mode tag. FROZEN to mirror the client's DailyModeResolver.DOW_DEFAULTS
// (app/.../domain/DailyModeResolver.kt); JS getUTCDay(): 0=Sun … 6=Sat.
const WEEKDAY_MODE_TAGS = {
0: 'mode_tiny_date_night', // Sunday — Slow Burn Sunday
1: 'mode_soft_monday', // Monday — Mood Check Monday
2: 'mode_snack_mission', // Tuesday — Tiny Win Tuesday
3: 'mode_no_phone_moment', // Wednesday — Real One Wednesday
4: 'mode_laugh_reset', // Thursday — Laugh It Off Thursday
5: 'mode_flirty_friday', // Friday — Flirty Friday
6: 'mode_weekend_side_quest', // Saturday — Side Quest Saturday
};
/** Days since epoch for a YYYY-MM-DD Chicago date mirrors LocalDate.toEpochDay() the client
* uses for its deterministic daily offset, so server and client land on the same question. */
function epochDayOf(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / 86400000);
}
/**
* 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.
*/
async function pickDailyQuestionId(dateStr) {
const db = admin.firestore();
// The whole free daily pool is tiny (~75 rows); fetch once and filter in memory to avoid a
// composite index on (active, isPremium, modeTag).
const snapshot = await db
.collection('questions')
.where('active', '==', true)
.where('isPremium', '==', false)
.get();
if (snapshot.empty) {
// Rollout fallback: keep the feature working until the questions pool is seeded.
return 'q_default_daily';
}
const docs = snapshot.docs;
const chosen = docs[Math.floor(Math.random() * docs.length)];
return chosen.id;
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 offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length;
return pool[offset].id;
}
/**
* Returns today's date as YYYY-MM-DD in America/Chicago time.
@ -203,35 +236,47 @@ function nextCstDateString(from) {
d.setUTCDate(d.getUTCDate() + 1);
return formatCstDate(d);
}
const d = new Date();
const cst = applyCstOffset(d);
cst.setUTCDate(cst.getUTCDate() + 1);
return formatCstDate(cst);
// Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h.
const todayChicago = parseCstDate(formatCstDate(new Date()));
todayChicago.setUTCDate(todayChicago.getUTCDate() + 1);
return formatCstDate(todayChicago);
}
function applyCstOffset(d) {
const utcMs = d.getTime();
const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000;
return new Date(cstMs);
/**
* UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST).
* DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted
* reveals by an hour for the ~8 months of the year Chicago observes daylight time).
* Pattern matches streakReminder.ts's Intl-based chicagoDateKey.
*/
function chicagoOffsetHours(atUtcMs) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).formatToParts(new Date(atUtcMs));
const get = (t) => { var _a; return Number((_a = parts.find((p) => p.type === t)) === null || _a === void 0 ? void 0 : _a.value); };
const localAsUtcMs = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute'));
return Math.round((atUtcMs - localAsUtcMs) / 3600000);
}
/** YYYY-MM-DD for the given instant in America/Chicago (en-CA gives ISO date order). */
function formatCstDate(d) {
const cst = applyCstOffset(d);
const y = cst.getUTCFullYear();
const m = String(cst.getUTCMonth() + 1).padStart(2, '0');
const day = String(cst.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
}
/** The UTC instant of midnight America/Chicago on the given calendar date. */
function parseCstDate(dateStr) {
const [y, m, day] = dateStr.split('-').map(Number);
// Build a UTC timestamp that represents midnight CST, then reverse the offset.
const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0);
return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000);
// Guess with the standard offset, then correct with the real offset at that instant.
const guessMs = Date.UTC(y, m - 1, day, 6);
const offset = chicagoOffsetHours(guessMs);
return new Date(Date.UTC(y, m - 1, day, offset));
}
/** The UTC instant of 6:00 PM America/Chicago on the given calendar date. */
function timestampAt6PmCst(dateStr) {
const d = parseCstDate(dateStr);
const utcMs = d.getTime();
// 6:00 PM CST == 18:00 CST == 00:00 UTC next day.
// We already have midnight CST in UTC form; add 18 hours.
const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000;
return admin.firestore.Timestamp.fromMillis(at6pmCstMs);
const [y, m, day] = dateStr.split('-').map(Number);
const guessMs = Date.UTC(y, m - 1, day, 18 + 6);
const offset = chicagoOffsetHours(guessMs);
return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset));
}
//# sourceMappingURL=assignDailyQuestion.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,38 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const assignDailyQuestion_1 = require("./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).
describe('Chicago date helpers (DST-safe)', () => {
it('labels a summer (CDT, UTC-5) evening with the correct local date', () => {
// 2026-07-08T03:30Z == 2026-07-07 22:30 CDT — the old -6 math said "07-07" too,
// but 2026-07-08T04:30Z == 2026-07-07 23:30 CDT is where they diverge:
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-07-08T03:30:00Z'))).toBe('2026-07-07');
// 23:30 CDT is still Jul 7 locally; hardcoded -6 called it Jul 7 22:30 → same date,
// but 2026-07-08T05:30Z == Jul 8 00:30 CDT; old math: Jul 7 23:30 → WRONG date.
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-07-08T05:30:00Z'))).toBe('2026-07-08');
});
it('labels a winter (CST, UTC-6) instant correctly', () => {
// 2026-01-08T05:30Z == 2026-01-07 23:30 CST
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-01-08T05:30:00Z'))).toBe('2026-01-07');
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-01-08T06:30:00Z'))).toBe('2026-01-08');
});
it('parses midnight Chicago with the seasonal offset', () => {
// Summer: midnight CDT == 05:00Z
expect((0, assignDailyQuestion_1.parseCstDate)('2026-07-07').toISOString()).toBe('2026-07-07T05:00:00.000Z');
// Winter: midnight CST == 06:00Z
expect((0, assignDailyQuestion_1.parseCstDate)('2026-01-07').toISOString()).toBe('2026-01-07T06:00:00.000Z');
});
it('puts the 6 PM reveal at 6 PM local in both seasons', () => {
// Summer: 18:00 CDT == 23:00Z
expect((0, assignDailyQuestion_1.timestampAt6PmCst)('2026-07-07').toDate().toISOString()).toBe('2026-07-07T23:00:00.000Z');
// Winter: 18:00 CST == 00:00Z next day
expect((0, assignDailyQuestion_1.timestampAt6PmCst)('2026-01-07').toDate().toISOString()).toBe('2026-01-08T00:00:00.000Z');
});
it('round-trips format(parse(d)) across the spring-forward boundary', () => {
for (const day of ['2026-03-07', '2026-03-08', '2026-03-09', '2026-11-01', '2026-11-02']) {
expect((0, assignDailyQuestion_1.formatCstDate)((0, assignDailyQuestion_1.parseCstDate)(day))).toBe(day);
}
});
});
//# sourceMappingURL=assignDailyQuestion.test.js.map

View File

@ -0,0 +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"}