mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266233a473 | ||
|
|
2bc69c2790 | ||
|
|
9da44217f7 |
No files matched your search
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: generate-release
|
||||
description: Generate a signed Sauci mobile release by bumping the public version, writing on-brand release notes, building and verifying iOS and Android binaries, committing release metadata, and tagging the release commit. Use for an actual production mobile release; use release-mobile for preparation, one-platform builds, uploads, or submission work that does not require the complete release lifecycle.
|
||||
---
|
||||
|
||||
# Generate release
|
||||
|
||||
Create a reproducible release whose tag identifies the reviewed release source
|
||||
and final platform build metadata. Read `apps/mobile/AGENTS.md`,
|
||||
`docs/releasing.md`, and the `release-mobile` and `commit` skills before acting. Read
|
||||
[references/release-notes-voice.md](references/release-notes-voice.md) before
|
||||
drafting notes and [references/artifact-verification.md](references/artifact-verification.md)
|
||||
before accepting a build.
|
||||
|
||||
A request to generate or create a release authorizes the scoped version edits,
|
||||
release notes, local builds, release commits, and one local annotated tag. It
|
||||
does not authorize pushing commits or tags, uploading binaries, submitting a
|
||||
store release, changing signing credentials, or regenerating native projects.
|
||||
|
||||
## Defaults
|
||||
|
||||
- Build both iOS and Android production binaries unless the user narrows the
|
||||
platforms.
|
||||
- Increment the patch version unless the user specifies `minor`, `major`, or an
|
||||
exact SemVer version.
|
||||
- Use `v<version>` for the annotated Git tag.
|
||||
- Write artifacts beneath ignored `dist/releases/v<version>/`; never commit
|
||||
binaries, credentials, logs, or build directories.
|
||||
|
||||
## Preflight
|
||||
|
||||
Fetch the remote and inspect the worktree, branch, upstream, existing tags,
|
||||
toolchains, EAS authentication, and signing availability without changing them.
|
||||
Require all of the following:
|
||||
|
||||
- a named branch, normally `main` for a production release;
|
||||
- a clean starting worktree with `HEAD` equal to its fetched upstream;
|
||||
- the previous reachable `v[0-9]*` release tag and no collision for the next tag;
|
||||
- consistent public versions across `app.json`, Android `versionName`, and every
|
||||
iOS `MARKETING_VERSION` (`scripts/set_version.mjs --check`);
|
||||
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
|
||||
- the configured production API/Auth/RevenueCat environment, without printing
|
||||
secret values.
|
||||
|
||||
Do not switch branches, discard work, repair credentials, push, or weaken branch
|
||||
protection merely to pass preflight. Stop with the exact blocker. Never run
|
||||
`expo prebuild` as part of this workflow.
|
||||
|
||||
## Prepare the release
|
||||
|
||||
1. Derive the next version with `scripts/set_version.mjs --bump <kind>` or set an
|
||||
explicit version with `--version <x.y.z>`. The script updates the checked-in
|
||||
public-version sources but deliberately leaves platform build numbers to EAS.
|
||||
2. Review the complete first-parent and ordinary commit diff from the previous
|
||||
reachable release tag to the starting commit. Inspect the actual product diff
|
||||
for every user-facing claim; commit subjects alone are not evidence.
|
||||
3. Add the new entry immediately below the title in `docs/release-notes.md`.
|
||||
Describe user-visible value, meaningful fixes, privacy or reliability changes,
|
||||
and only claims supported by the diff. Omit internal migrations, refactors,
|
||||
CI, infrastructure, dependency churn, and implementation jargon unless the
|
||||
user experiences a direct result. Use a temporary `Build numbers pending
|
||||
artifact verification` line until the binaries have been inspected; never
|
||||
guess or predict build numbers.
|
||||
4. Run `npm run verify:full` and the native acceptance required by the
|
||||
`release-mobile` skill. A missing prerequisite is a failure/limitation, not a
|
||||
pass.
|
||||
5. Review the scoped diff and create `chore(release): prepare v<version>`. Do not
|
||||
stage unrelated paths. This clean release-preparation commit must descend
|
||||
directly from the fetched upstream commit.
|
||||
|
||||
## Build and capture platform metadata
|
||||
|
||||
Build from the clean prepared source using the package scripts in
|
||||
`apps/mobile/package.json`, with `EAS_LOCAL_BUILD_ARTIFACTS_DIR` set to the
|
||||
versioned artifact directory. When building both platforms, build Android first,
|
||||
then iOS.
|
||||
|
||||
After each build:
|
||||
|
||||
1. Inspect the worktree before doing anything else. Accept only the documented
|
||||
EAS build-number changes in `app.json`, that platform's native version source,
|
||||
and the iOS widget plist when applicable. Treat any implementation, resource,
|
||||
dependency, or native-project regeneration as a failed build.
|
||||
2. Independently inspect the binary for package/bundle identifier, public
|
||||
version, platform build number, production endpoint configuration where
|
||||
inspectable, and signing identity/fingerprint. Require the embedded public
|
||||
version to equal the requested version.
|
||||
3. Record the verified platform build number in the new release-note entry.
|
||||
4. Commit only the expected metadata and release-note update as
|
||||
`chore(<platform>): record v<version> build <number>`. The worktree must be
|
||||
clean before starting the next platform build.
|
||||
|
||||
Do not accept a successful EAS exit code as proof of a valid artifact. If either
|
||||
requested platform fails, do not tag a partial release.
|
||||
|
||||
## Tag and prove the result
|
||||
|
||||
Re-run focused version checks and `npm run verify:fast`. Confirm every requested
|
||||
artifact exists, has a SHA-256 digest, passed independent version/signature
|
||||
inspection, and was produced from the release commit chain. Require a clean
|
||||
worktree, then create the annotated local tag `v<version>` at `HEAD`.
|
||||
|
||||
Read back the tag object and prove:
|
||||
|
||||
- the tag resolves to `HEAD`;
|
||||
- the tag version equals all public-version sources and embedded artifacts;
|
||||
- recorded build numbers equal the embedded platform build numbers;
|
||||
- no binary or ignored build output entered the commit;
|
||||
- the release commits descend from the fetched starting branch commit;
|
||||
- for a two-platform release, the Android artifact's source commit differs from
|
||||
the final tag only by the later verified iOS build metadata.
|
||||
|
||||
Report the branch, tag, commit, release-note summary, artifact paths, versions,
|
||||
build numbers, signatures, SHA-256 digests, checks run, and any environmental
|
||||
limitations. State explicitly that the tag and commits remain local unless a
|
||||
separate authorized push was completed.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Generate Release"
|
||||
short_description: "Bump, build, note, and tag Sauci releases"
|
||||
default_prompt: "Use $generate-release to create the next signed Sauci mobile release for iOS and Android."
|
||||
@@ -0,0 +1,39 @@
|
||||
# Release artifact verification
|
||||
|
||||
Use the strongest locally available platform tools and record the commands and
|
||||
results. Never print provisioning profiles, keys, tokens, or environment values.
|
||||
|
||||
## Android AAB
|
||||
|
||||
Require a production `.aab`. Inspect its manifest with `bundletool dump manifest`
|
||||
or Android SDK `apkanalyzer`, and verify:
|
||||
|
||||
- package `com.sauci.app`;
|
||||
- `versionName` equals the release version;
|
||||
- `versionCode` equals the recorded Android build number.
|
||||
|
||||
Verify the archive signature with `jarsigner -verify -verbose -certs`. Compare the
|
||||
signing certificate SHA-1 to the production fingerprint documented in
|
||||
`docs/releasing.md`; do not copy credentials into commands or reports. Calculate
|
||||
the artifact SHA-256 with `shasum -a 256`.
|
||||
|
||||
## iOS IPA
|
||||
|
||||
Extract the `.ipa` into a new temporary directory created with `mktemp -d`. Read
|
||||
the application `Info.plist` with `/usr/libexec/PlistBuddy` or `plutil` and verify:
|
||||
|
||||
- `CFBundleIdentifier` is `com.sauci.app`;
|
||||
- `CFBundleShortVersionString` equals the release version;
|
||||
- `CFBundleVersion` equals the recorded iOS build number.
|
||||
|
||||
Use `codesign -dvvv` and `codesign --verify --deep --strict` on the extracted app
|
||||
to inspect and verify signing without exposing the embedded profile. Calculate the
|
||||
original `.ipa` SHA-256 with `shasum -a 256`. Remove only the explicit temporary
|
||||
extraction directory after inspection.
|
||||
|
||||
## Source correspondence
|
||||
|
||||
For both platforms, retain the build command, artifact path, build completion
|
||||
time, and digest in the final report. Compare the post-build tracked diff with the
|
||||
documented allowed version files. A binary with the right version but unexpected
|
||||
source regeneration, wrong signing, or an unverifiable manifest is not accepted.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Sauci release-note voice
|
||||
|
||||
Sauci sounds warm, confident, inviting, and quietly playful. It is a premium
|
||||
couples app about private connection and shared curiosity: supportive without
|
||||
being clinical, polished without sounding corporate, and intimate without being
|
||||
graphic or presumptuous.
|
||||
|
||||
## Shape
|
||||
|
||||
Use a short opening sentence followed by three to six compact bullets. Lead with
|
||||
the change that most improves a couple's experience. Combine related work into
|
||||
one benefit rather than mirroring the commit list. Include a brief closing line
|
||||
only when it adds warmth or useful context.
|
||||
|
||||
In `docs/release-notes.md`, use:
|
||||
|
||||
```markdown
|
||||
## v1.2.3 — 29 August 2026
|
||||
_iOS build 44 · Android build 48_
|
||||
|
||||
A little more ease, clarity, and connection in every check-in.
|
||||
|
||||
- **Closer conversations** — Find the prompts that feel right for both of you.
|
||||
- **Smoother moments** — Chats and matches now feel quicker and more reliable.
|
||||
- **More control** — Clearer choices help keep your shared space comfortable.
|
||||
```
|
||||
|
||||
Adapt the words to the evidence; do not reuse this copy when those claims are not
|
||||
present in the release. If only one platform was requested, record only its build.
|
||||
|
||||
## Language
|
||||
|
||||
- Address the couple's experience with `you`, `your partner`, `together`, or
|
||||
`your shared space` where natural.
|
||||
- Prefer concrete benefits: `pick up where you left off`, `find the right prompt`,
|
||||
`keep conversations flowing`, `stay in control`.
|
||||
- Use restrained warmth. One gentle phrase is enough; avoid forced innuendo,
|
||||
breathless hype, exclamation marks, emoji, and claims that Sauci will improve or
|
||||
fix a relationship.
|
||||
- Prefer `new`, `smoother`, `clearer`, `more private`, and `more reliable` to
|
||||
`revolutionary`, `game-changing`, `massive`, or `best ever`.
|
||||
- Explain a fix as the experience restored, not the defect mechanics. Name a
|
||||
security or privacy change plainly when users need to understand it.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
Release notes describe shipped behavior only. Do not claim a feature from a plan,
|
||||
flagged-off path, migration, test, or commit title without confirming the runtime
|
||||
path in the diff and release configuration. Do not expose internal service names,
|
||||
database details, moderation logic, security-sensitive implementation, or content
|
||||
that the product intentionally keeps private between partners.
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "../../../..");
|
||||
const files = {
|
||||
app: path.join(repoRoot, "apps/mobile/app.json"),
|
||||
android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"),
|
||||
ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"),
|
||||
};
|
||||
|
||||
const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`set_version: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseVersion(value, label) {
|
||||
const match = semverPattern.exec(value);
|
||||
if (!match) fail(`${label} must be an exact numeric SemVer version (x.y.z)`);
|
||||
return match.slice(1).map(Number);
|
||||
}
|
||||
|
||||
function readState() {
|
||||
const appRaw = fs.readFileSync(files.app, "utf8");
|
||||
const androidRaw = fs.readFileSync(files.android, "utf8");
|
||||
const iosRaw = fs.readFileSync(files.ios, "utf8");
|
||||
const appVersion = JSON.parse(appRaw).expo?.version;
|
||||
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map(
|
||||
(match) => match[1].trim(),
|
||||
);
|
||||
|
||||
if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version");
|
||||
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
|
||||
if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values");
|
||||
|
||||
const versions = [appVersion, ...androidVersions, ...iosVersions];
|
||||
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
|
||||
if (new Set(versions).size !== 1) {
|
||||
fail(`public versions disagree: ${versions.join(", ")}`);
|
||||
}
|
||||
|
||||
return { appRaw, androidRaw, iosRaw, current: appVersion, iosCount: iosVersions.length };
|
||||
}
|
||||
|
||||
function nextVersion(current, bump) {
|
||||
const [major, minor, patch] = parseVersion(current, "current version");
|
||||
if (bump === "major") return `${major + 1}.0.0`;
|
||||
if (bump === "minor") return `${major}.${minor + 1}.0`;
|
||||
if (bump === "patch") return `${major}.${minor}.${patch + 1}`;
|
||||
fail("--bump must be patch, minor, or major");
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const check = args.length === 1 && args[0] === "--check";
|
||||
const bumpIndex = args.indexOf("--bump");
|
||||
const versionIndex = args.indexOf("--version");
|
||||
|
||||
if (!check && (bumpIndex === -1) === (versionIndex === -1)) {
|
||||
fail("use --check, --bump patch|minor|major, or --version x.y.z");
|
||||
}
|
||||
|
||||
const state = readState();
|
||||
if (check) {
|
||||
console.log(`public version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const desired =
|
||||
bumpIndex !== -1
|
||||
? nextVersion(state.current, args[bumpIndex + 1])
|
||||
: (parseVersion(args[versionIndex + 1], "--version"), args[versionIndex + 1]);
|
||||
|
||||
if (desired === state.current) fail(`requested version is already ${state.current}`);
|
||||
const currentParts = parseVersion(state.current, "current version");
|
||||
const desiredParts = parseVersion(desired, "requested version");
|
||||
const increases = desiredParts.some(
|
||||
(part, index) => part > currentParts[index] && desiredParts.slice(0, index).every((value, prior) => value === currentParts[prior]),
|
||||
);
|
||||
if (!increases) fail(`requested version ${desired} must be greater than ${state.current}`);
|
||||
|
||||
const appRaw = state.appRaw.replace(
|
||||
/(\"version\"\s*:\s*\")([^\"]+)(\")/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const androidRaw = state.androidRaw.replace(
|
||||
/(\bversionName\s+["'])([^"']+)(["'])/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const iosRaw = state.iosRaw.replace(
|
||||
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(files.app, appRaw);
|
||||
fs.writeFileSync(files.android, androidRaw);
|
||||
fs.writeFileSync(files.ios, iosRaw);
|
||||
console.log(`updated public version ${state.current} -> ${desired}`);
|
||||
@@ -10,9 +10,12 @@ scoped instructions. Confirm target platform and release profile. Run repository
|
||||
verification plus a native acceptance pass before starting a release build.
|
||||
|
||||
Before a build from the current upstream branch, fetch, require a clean worktree,
|
||||
and prove `HEAD` matches its upstream. Sauci tracks its native iOS and Android
|
||||
projects, so routine release builds must package those checked-in sources. Do not
|
||||
run `expo prebuild` first; native regeneration is a separate reviewed change.
|
||||
and prove `HEAD` matches its upstream. The `generate-release` skill may instead
|
||||
build a clean, scoped release-preparation commit that descends directly from that
|
||||
fetched upstream; between its multi-platform builds it commits only independently
|
||||
verified EAS version metadata. Sauci tracks its native iOS and Android projects,
|
||||
so routine release builds must package those checked-in sources. Do not run
|
||||
`expo prebuild` first; native regeneration is a separate reviewed change.
|
||||
|
||||
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
|
||||
Never infer upload or submission authority from build authorization. Store upload
|
||||
|
||||
@@ -83,7 +83,10 @@ export class PostgresRepository implements ApiRepository {
|
||||
target: profiles.id,
|
||||
set: {
|
||||
email: sql`coalesce(excluded.email, ${profiles.email})`,
|
||||
authSyncedAt: new Date(),
|
||||
// Keep the write on the same clock as the column default. API and
|
||||
// PostgreSQL hosts can differ slightly, which must not move this
|
||||
// monotonic sync marker backwards.
|
||||
authSyncedAt: sql`now()`,
|
||||
},
|
||||
}).returning();
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
||||
import { GradientBackground } from '../../src/components/ui';
|
||||
import { Paywall } from '../../src/components/paywall';
|
||||
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
|
||||
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery';
|
||||
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
||||
import { colors, spacing, typography, radius } from '../../src/theme';
|
||||
import { Events } from '../../src/lib/analytics';
|
||||
import { colors, spacing, typography } from '../../src/theme';
|
||||
import type { QuestionPack, Category } from '../../src/types';
|
||||
|
||||
const MAX_CONTENT_WIDTH = 600;
|
||||
@@ -69,14 +71,21 @@ function groupPacksByCategory(
|
||||
export default function DiscoveryScreen() {
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
|
||||
const { pendingQuestions, fetchPendingQuestions } = useMatchStore();
|
||||
const { subscription } = useSubscriptionStore();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [headerHeight, setHeaderHeight] = useState(130);
|
||||
const lastPromptImpression = useRef<string | null>(null);
|
||||
|
||||
// Check all sources of premium access: user flag, partner flag, or active subscription
|
||||
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const pendingQuestionIds = useMemo(
|
||||
() => pendingQuestions.map(question => question.id).join(','),
|
||||
[pendingQuestions],
|
||||
);
|
||||
|
||||
// Load the catalog once. Returning to this tab shows what is cached; answering a
|
||||
// question invalidates it, and StreakDisplay owns the streak, so neither needs a
|
||||
@@ -84,7 +93,17 @@ export default function DiscoveryScreen() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void ensurePacksLoaded();
|
||||
}, [ensurePacksLoaded])
|
||||
void fetchPendingQuestions();
|
||||
|
||||
if (pendingQuestionIds && lastPromptImpression.current !== pendingQuestionIds) {
|
||||
Events.pendingQuestionsPromptViewed(pendingQuestions.length);
|
||||
lastPromptImpression.current = pendingQuestionIds;
|
||||
}
|
||||
|
||||
return () => {
|
||||
lastPromptImpression.current = null;
|
||||
};
|
||||
}, [ensurePacksLoaded, fetchPendingQuestions, pendingQuestionIds, pendingQuestions.length])
|
||||
);
|
||||
|
||||
// Group packs by category
|
||||
@@ -96,6 +115,18 @@ export default function DiscoveryScreen() {
|
||||
// Check if user has no packs enabled (empty state)
|
||||
const hasNoPacks = packs.length === 0;
|
||||
|
||||
const handlePendingQuestionsPress = useCallback(() => {
|
||||
const firstQuestion = pendingQuestions[0];
|
||||
if (!firstQuestion) return;
|
||||
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
Events.pendingQuestionsPromptTapped(pendingQuestions.length);
|
||||
router.push({
|
||||
pathname: '/(app)/swipe',
|
||||
params: { mode: 'pending', startQuestionId: firstQuestion.question.id },
|
||||
});
|
||||
}, [pendingQuestions, router]);
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View
|
||||
@@ -117,16 +148,23 @@ export default function DiscoveryScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
||||
<PendingQuestionsCard
|
||||
questions={pendingQuestions}
|
||||
partnerName={partner?.name}
|
||||
onPress={handlePendingQuestionsPress}
|
||||
delay={50}
|
||||
/>
|
||||
|
||||
{/* Shared streak - hidden entirely for couples with nothing running */}
|
||||
<View style={styles.streakSlot}>
|
||||
<StreakDisplay delay={50} />
|
||||
<StreakDisplay delay={100} />
|
||||
</View>
|
||||
|
||||
{/* Live Draw Activity Tile */}
|
||||
<LiveDrawTile delay={100} />
|
||||
<LiveDrawTile delay={150} />
|
||||
|
||||
{/* Dares */}
|
||||
<DaresTile delay={150} />
|
||||
<DaresTile delay={200} />
|
||||
|
||||
{/* Content Rows by Category */}
|
||||
{packsByCategory.map((group, index) => (
|
||||
@@ -136,7 +174,7 @@ export default function DiscoveryScreen() {
|
||||
packs={group.packs}
|
||||
isPremiumUser={isPremiumUser}
|
||||
categoryId={group.category.id}
|
||||
delay={200 + index * 100}
|
||||
delay={250 + index * 100}
|
||||
getPackProgress={getPackProgress}
|
||||
onShowPaywall={() => setShowPaywall(true)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { fireEvent, render } from '@/test/test-utils';
|
||||
import { PendingQuestionsCard, formatWaitingSince } from '@/components/discovery/PendingQuestionsCard';
|
||||
import type { PendingQuestion } from '@/store';
|
||||
|
||||
const pendingQuestion = (id: string, partnerAnsweredAt: string): PendingQuestion => ({
|
||||
id: `response-${id}`,
|
||||
partnerAnsweredAt,
|
||||
question: {
|
||||
id,
|
||||
pack_id: 'pack-1',
|
||||
text: `Question ${id}`,
|
||||
intensity: 1,
|
||||
created_at: '2026-08-01T00:00:00.000Z',
|
||||
pack: { id: 'pack-1', name: 'Connection', icon: 'heart' },
|
||||
},
|
||||
});
|
||||
|
||||
describe('PendingQuestionsCard', () => {
|
||||
it('stays out of Home when there is nothing to answer', () => {
|
||||
const { queryByTestId } = render(
|
||||
<PendingQuestionsCard questions={[]} partnerName="Sam" onPress={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(queryByTestId('pending-questions-card')).toBeNull();
|
||||
});
|
||||
|
||||
it('names the partner, shows the count, and preserves answer privacy', () => {
|
||||
const questions = [
|
||||
pendingQuestion('q1', '2026-08-29T09:00:00.000Z'),
|
||||
pendingQuestion('q2', '2026-08-29T10:00:00.000Z'),
|
||||
];
|
||||
const { getByText, getByLabelText } = render(
|
||||
<PendingQuestionsCard questions={questions} partnerName="Sam" onPress={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(getByText('Sam has answered 2 questions')).toBeTruthy();
|
||||
expect(getByText('Answer yours to see where you match. Their answers stay private until you respond.')).toBeTruthy();
|
||||
expect(getByLabelText('2 questions waiting')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('starts the answer action from its accessible CTA', () => {
|
||||
const onPress = jest.fn();
|
||||
const { getByTestId } = render(
|
||||
<PendingQuestionsCard
|
||||
questions={[pendingQuestion('q1', '2026-08-29T09:00:00.000Z')]}
|
||||
partnerName="Sam"
|
||||
onPress={onPress}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.press(getByTestId('pending-questions-answer'));
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatWaitingSince', () => {
|
||||
const now = new Date('2026-08-29T12:00:00.000Z');
|
||||
|
||||
it('formats useful compact waiting ages', () => {
|
||||
expect(formatWaitingSince('2026-08-29T11:59:30.000Z', now)).toBe('just now');
|
||||
expect(formatWaitingSince('2026-08-29T11:42:00.000Z', now)).toBe('18m ago');
|
||||
expect(formatWaitingSince('2026-08-29T09:00:00.000Z', now)).toBe('3h ago');
|
||||
expect(formatWaitingSince('2026-08-27T12:00:00.000Z', now)).toBe('2d ago');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { router } from 'expo-router';
|
||||
import { handleNotificationResponse } from '@/lib/notifications';
|
||||
import { useMatchStore } from '@/store/matchStore';
|
||||
|
||||
jest.mock('expo-router', () => ({
|
||||
router: { push: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('expo-notifications', () => ({
|
||||
setNotificationHandler: jest.fn(),
|
||||
addNotificationResponseReceivedListener: jest.fn(),
|
||||
getLastNotificationResponseAsync: jest.fn(),
|
||||
getPermissionsAsync: jest.fn(),
|
||||
requestPermissionsAsync: jest.fn(),
|
||||
getExpoPushTokenAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/crashlytics', () => ({
|
||||
captureError: jest.fn(),
|
||||
}));
|
||||
|
||||
const notificationResponse = (type: string) => ({
|
||||
notification: {
|
||||
request: {
|
||||
content: { data: { type } },
|
||||
},
|
||||
},
|
||||
}) as never;
|
||||
|
||||
describe('handleNotificationResponse', () => {
|
||||
beforeEach(() => {
|
||||
useMatchStore.setState({ currentView: 'active' });
|
||||
});
|
||||
|
||||
it('opens the answer queue for a catch-up reminder', () => {
|
||||
handleNotificationResponse(notificationResponse('catchup_reminder'));
|
||||
|
||||
expect(useMatchStore.getState().currentView).toBe('pending');
|
||||
expect(router.push).toHaveBeenCalledWith('/(app)/swipe?mode=pending');
|
||||
});
|
||||
|
||||
it('ignores unknown notification types', () => {
|
||||
handleNotificationResponse(notificationResponse('unknown'));
|
||||
|
||||
expect(router.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import type { PendingQuestion } from '../../store';
|
||||
import { colors, gradients, radius, shadows, spacing, typography } from '../../theme';
|
||||
|
||||
interface PendingQuestionsCardProps {
|
||||
questions: PendingQuestion[];
|
||||
partnerName?: string | null;
|
||||
onPress: () => void;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function formatWaitingSince(dateString: string, now = new Date()): string {
|
||||
const answeredAt = new Date(dateString);
|
||||
const diffMs = Math.max(0, now.getTime() - answeredAt.getTime());
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return answeredAt.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function PendingQuestionsCard({
|
||||
questions,
|
||||
partnerName,
|
||||
onPress,
|
||||
delay = 0,
|
||||
}: PendingQuestionsCardProps) {
|
||||
const oldestQuestion = useMemo(
|
||||
() => questions.reduce<PendingQuestion | null>((oldest, question) => {
|
||||
if (!oldest) return question;
|
||||
return new Date(question.partnerAnsweredAt) < new Date(oldest.partnerAnsweredAt)
|
||||
? question
|
||||
: oldest;
|
||||
}, null),
|
||||
[questions],
|
||||
);
|
||||
|
||||
if (!oldestQuestion) return null;
|
||||
|
||||
const count = questions.length;
|
||||
const subject = partnerName?.trim() || 'Your partner';
|
||||
const questionLabel = count === 1 ? 'question' : 'questions';
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(delay).duration(450).springify()}
|
||||
style={styles.container}
|
||||
testID="pending-questions-card"
|
||||
>
|
||||
<View style={[styles.card, shadows.md]}>
|
||||
<View style={styles.accent} />
|
||||
|
||||
<View style={styles.labelRow}>
|
||||
<View style={styles.labelGroup}>
|
||||
<View style={styles.statusDot} />
|
||||
<Text style={styles.label}>Your turn</Text>
|
||||
</View>
|
||||
<View style={styles.countBadge} accessibilityLabel={`${count} ${questionLabel} waiting`}>
|
||||
<Text style={styles.countText}>{count}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>
|
||||
{subject} has answered {count} {questionLabel}
|
||||
</Text>
|
||||
<Text style={styles.description}>
|
||||
Answer yours to see where you match. Their answers stay private until you respond.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.85}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Answer ${count} waiting ${questionLabel}`}
|
||||
testID="pending-questions-answer"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string, ...string[]]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.buttonText}>Answer now</Text>
|
||||
<Ionicons name="arrow-forward" size={17} color={colors.text} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.waitingRow}>
|
||||
<Ionicons name="time-outline" size={13} color={colors.textTertiary} />
|
||||
<Text style={styles.waitingText}>
|
||||
Oldest waiting · {formatWaitingSince(oldestQuestion.partnerAnsweredAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
card: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(232, 164, 174, 0.36)',
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
accent: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderBottomLeftRadius: 96,
|
||||
backgroundColor: colors.primaryLight,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
labelGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.primary,
|
||||
shadowColor: colors.primary,
|
||||
shadowOpacity: 0.55,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
},
|
||||
label: {
|
||||
...typography.caption1,
|
||||
color: colors.premium.rose,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1.2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
countBadge: {
|
||||
minWidth: 32,
|
||||
height: 26,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
countText: {
|
||||
...typography.caption1,
|
||||
color: colors.text,
|
||||
fontWeight: '800',
|
||||
},
|
||||
title: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
marginTop: spacing.md,
|
||||
paddingRight: spacing.xl,
|
||||
},
|
||||
description: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
button: {
|
||||
minHeight: 46,
|
||||
borderRadius: radius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
buttonText: {
|
||||
...typography.callout,
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
waitingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
waitingText: {
|
||||
...typography.caption2,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
});
|
||||
@@ -4,3 +4,4 @@ export { CompactHeader } from './CompactHeader';
|
||||
export { CircularProgress } from './CircularProgress';
|
||||
export { LiveDrawTile } from './LiveDrawTile';
|
||||
export { DaresTile } from './DaresTile';
|
||||
export { PendingQuestionsCard } from './PendingQuestionsCard';
|
||||
@@ -216,6 +216,10 @@ export const Events = {
|
||||
questionSkipped: () => logEvent("question_skipped"),
|
||||
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
|
||||
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
|
||||
pendingQuestionsPromptViewed: (count: number) =>
|
||||
logEvent("pending_questions_prompt_viewed", { question_count: count }),
|
||||
pendingQuestionsPromptTapped: (count: number) =>
|
||||
logEvent("pending_questions_prompt_tapped", { question_count: count }),
|
||||
|
||||
// Chat events
|
||||
matchViewed: () => logEvent("match_viewed"),
|
||||
|
||||
@@ -23,7 +23,7 @@ Notifications.setNotificationHandler({
|
||||
|
||||
// Types
|
||||
export interface NotificationData {
|
||||
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary";
|
||||
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary" | "catchup_reminder";
|
||||
match_id?: string;
|
||||
message_id?: string;
|
||||
count?: number;
|
||||
@@ -36,6 +36,7 @@ const notificationTypes = new Set<NotificationData["type"]>([
|
||||
"partner_activity",
|
||||
"nudge",
|
||||
"weekly_summary",
|
||||
"catchup_reminder",
|
||||
]);
|
||||
|
||||
function isNotificationData(value: unknown): value is NotificationData {
|
||||
@@ -261,6 +262,12 @@ export function handleNotificationResponse(
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "catchup_reminder") {
|
||||
useMatchStore.getState().setCurrentView('pending');
|
||||
router.push("/(app)/swipe?mode=pending");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.match_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user