Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
44f4f61
feat(phase-0a): edge proxy + key rotation + gitleaks + sentry (securi…
nhonhgeargame May 26, 2026
52a6f4b
feat(phase-0b): supabase platform + auth + M\u01a1 persona + perf sur…
nhonhgeargame May 26, 2026
5687698
feat(phase-1): F1 Remix v0.5 + F-Card recap + F8 Mood Memory + A2 Car…
nhonhgeargame May 27, 2026
3aabec4
feat(phase-2): F4 Affiliate + F3-Lite Map + A3 Du\u1eddng V\u1ec1 Qu\…
nhonhgeargame May 27, 2026
6ab4a11
feat(phase-3): M\u01a1's Notebook + S\u00f3ng \u0110i + Personal Worl…
nhonhgeargame May 27, 2026
4f9eea2
feat(phase-4): Personal 3D World + Anti-Itinerary + Data Portability
nhonhgeargame May 27, 2026
51fd3a0
feat(result): hero reveal + Mơ-voice + vitals + view modes + reel pre…
nhonhgeargame May 27, 2026
24e9ba2
fix(dev): local dev no longer needs deployed Cloudflare Worker
nhonhgeargame May 27, 2026
b441c91
fix(e2e): bind vite to 127.0.0.1 + strictPort so webServer ready-chec…
nhonhgeargame May 27, 2026
b54a774
fix(gemini): disable thinking + lower output cap; harden e2e selectors
nhonhgeargame May 27, 2026
e2d36ff
fix(dev): npm run dev always uses real Gemini; fixture clearly marked…
nhonhgeargame May 27, 2026
024cf72
fix(ci): unblock PR #1 — redact secret literal + fix typecheck
nhonhgeargame May 27, 2026
a312131
Merge remote-tracking branch 'origin/feature/phase-0a-edge-proxy' int…
nhonhgeargame May 27, 2026
d6e667e
Merge remote-tracking branch 'origin/feature/phase-0b-platform' into …
nhonhgeargame May 27, 2026
42e3e9f
Merge remote-tracking branch 'origin/feature/phase-1-features' into f…
nhonhgeargame May 27, 2026
b0bb191
Merge remote-tracking branch 'origin/feature/phase-2-features' into f…
nhonhgeargame May 27, 2026
560bb44
Merge remote-tracking branch 'origin/feature/phase-3-stretch' into fe…
nhonhgeargame May 27, 2026
e621e9e
fix(ci): allowlist .sisyphus/*.md in gitleaks
nhonhgeargame May 27, 2026
78edf15
Merge remote-tracking branch 'origin/feature/phase-0a-edge-proxy' int…
nhonhgeargame May 27, 2026
bc9d481
Merge remote-tracking branch 'origin/feature/phase-0b-platform' into …
nhonhgeargame May 27, 2026
e946c4c
Merge remote-tracking branch 'origin/feature/phase-1-features' into f…
nhonhgeargame May 27, 2026
bc610bc
Merge remote-tracking branch 'origin/feature/phase-2-features' into f…
nhonhgeargame May 27, 2026
7f36775
Merge remote-tracking branch 'origin/feature/phase-3-stretch' into fe…
nhonhgeargame May 27, 2026
8849dcf
Merge remote-tracking branch 'origin/main' into feature/phase-4-stretch
nhonhgeargame May 27, 2026
480040e
Merge remote-tracking branch 'origin/main' into feature/phase-4-stretch
nhonhgeargame May 27, 2026
2305dad
Merge remote-tracking branch 'origin/main' into feature/phase-4-stretch
nhonhgeargame May 27, 2026
bbeca9c
Merge remote-tracking branch 'origin/main' into feature/phase-4-stretch
nhonhgeargame May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ dist-ssr
*.sln
*.sw?
.vercel

# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
64 changes: 64 additions & 0 deletions .sisyphus/PHASE_4_HUMAN_ACTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Phase 4 — Human Actions Required Before Cutover

> Stacked on PR #5. Merge order: PR #1 → #2 → #3 → #4 → #5 → #6.

## What's in this PR

### 1. Personal 3D World (real Three.js scene)
- `services/personalWorldScene.ts` — region-aware monument selection from trip data. 9 monument kinds (mountain, palm, pagoda, lantern, paddyField, cafeTable, riverBoat, lighthouse, tree); kind picked deterministically per `(trip.id, destination)` hash; positions on a disk with radius growing 4 → 8 with trip count.
- `components/three/PersonalWorldMonuments.tsx` — Three.js group with per-kind procedural geometry (cone/sphere/box primitives, no GLB assets needed).
- `components/three/PersonalWorldCanvas.tsx` — react-three/fiber canvas with auto-rotating OrbitControls, drei Stars backdrop, OSM-quality lighting. Lazy-imported so it doesn't bloat the main bundle.
- `components/PersonalWorldScene.tsx` — modal wrapper, loads up to 200 owned trips via `listOwnedTrips`, renders the scene or an empty-state if no trips. Closeable.
- Entry: new "🌳 Thế giới" button in the top-right button group (shown only when authed).

### 2. Anti-Itinerary mode
- `services/antiItinerary.ts` — Gemini flash-lite + Mơ persona, structured JSON output with three fields: `vibe`, `direction`, `whisper`. Strict no-schedule, no-time, no-address constraint in the prompt.
- `components/AntiItineraryView.tsx` — full-screen contemplative view with purple gradient backdrop, three-section layout (Vibe → Direction → Whisper), graceful rate-limit/budget-exceeded handling, "I'll just go" + "Give me a normal plan instead" escape hatches.
- Entry: new "🌒 Thử Anti-Itinerary" button in the result view (shown only when there's a `lastFormData` to regenerate from). Falls back to the normal generation flow if user wants a real schedule.

### 3. Trip data export (Decree 13 / GDPR portability)
- `services/dataExport.ts` — bundles user profile, preferences, trips, and consent log into a single JSON archive. `downloadArchive()` triggers a client-side download with no server roundtrip beyond the existing Supabase queries.
- `services/dataExport.requestAccountDeletionViaEdgeFunction()` — calls the `delete-account` Supabase Edge Function shipped in PR #2.
- `components/DataPortabilityPanel.tsx` — two-section panel: "Tải xuống dữ liệu" (data export) and "Yêu cầu xoá tài khoản" (account deletion with two-step confirmation).
- Entry: top-right "⚙️ Tài khoản" button (shown only when authed; falls back to "Đăng nhập" otherwise) opens the panel in a modal overlay.

## Tests
- `services/__tests__/personalWorldScene.test.ts` (5 tests): deduplication of destinations, region→kind mapping, position bounding, deterministic output.
- `services/__tests__/dataExport.test.ts` (1 test): export format version invariant.

## Verification snapshot
- **Client: 106/106 tests pass** (was 100 in Phase 3)
- **Worker: 40/40 tests pass** (unchanged)
- Frontend typecheck clean except 2 pre-existing errors in `ItineraryDisplay.tsx` and `LoadingAnimation.tsx`
- Build succeeds

## 🔴 Human Actions Required

### 1. Verify `delete-account` Edge Function is deployed (from PR #2)
The Data Portability panel calls `supabase.functions.invoke('delete-account', ...)`. If you skipped that step in PR #2:
```bash
supabase functions deploy delete-account
supabase secrets set SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key>
```

### 2. Optional: tune Personal 3D World performance
The scene mounts up to 200 monuments. On low-end Android, that's ~600 mesh draws. If you see frame drops:
- Reduce `listOwnedTrips(user.id, 200)` to `100` in `components/PersonalWorldScene.tsx`.
- Or add a `<Suspense>`-wrapped chunk loader with progressive monument reveal (~10 every 100ms).

### 3. Anti-Itinerary cost considerations
Each Anti-Itinerary generation consumes one Gemini call from the user's daily quota — same as a normal trip. Consider gating behind paid plan if you want to cap the cost. The prompt requests `flash-lite` model (cheaper than `flash`).

## Smoke test after deploy

1. **Personal 3D World**: log in as a user with 3+ trips → click "🌳 Thế giới" → scene loads with N monuments matching trip count → drag to rotate, scroll to zoom → "Đóng" closes.
2. **Anti-Itinerary**: generate a normal trip, then click "🌒 Thử Anti-Itinerary" → see vibe/direction/whisper sections appear within 5s → click "Cho tôi một lịch trình bình thường thay thế" → returns to normal generation.
3. **Data export**: click "⚙️ Tài khoản" → "Tải xuống dữ liệu" → file downloads with name `moodtrip-export-<userhash>-<date>.json` → inspect contents, verify trips + preferences + consent log are all present.
4. **Account deletion**: click "Yêu cầu xoá tài khoản" → two-step confirmation → on confirm, account is deleted, session signed out.

## Risks

1. **Three.js bundle bloat** — Phase 0b lazy-mounted NatureScene; Phase 4 lazy-loads `PersonalWorldCanvas`. Both share the `@react-three/fiber` + drei stack. Verify total initial JS gzip stays < 300KB via `ohmyperf`.
2. **Anti-Itinerary JSON parsing** — the model occasionally returns "I cannot help with that" style refusals. Current code throws `INVALID_ANTI_ITINERARY`; user sees a generic error. Consider adding a retry with stricter system prompt.
3. **Data export size** — at 1000 trips per user (the cap), the JSON can hit ~10 MB. Browser downloads handle that fine, but consider streaming via a Worker endpoint at 100K MAU scale.
4. **Account deletion is irreversible** — the two-step confirmation is the only guard. Consider adding a 7-day soft-delete window with reversal email if you want safety margin.
122 changes: 102 additions & 20 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import { TripMap } from './components/TripMap';
import { MoNotebookModal } from './components/MoNotebookModal';
import { PublicShareButton } from './components/PublicShareButton';
import { PersonalWorldBadge } from './components/PersonalWorldBadge';
import { PersonalWorldScene } from './components/PersonalWorldScene';
import { AntiItineraryView } from './components/AntiItineraryView';
import { DataPortabilityPanel } from './components/DataPortabilityPanel';
import { generateAntiItinerary } from './services/antiItinerary';
import { useAuth } from './services/useAuth';
Comment on lines +28 to 30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The import of generateAntiItinerary is unused in this file and can be safely removed to keep the imports clean.

Suggested change
import { DataPortabilityPanel } from './components/DataPortabilityPanel';
import { generateAntiItinerary } from './services/antiItinerary';
import { useAuth } from './services/useAuth';
import { DataPortabilityPanel } from './components/DataPortabilityPanel';
import { useAuth } from './services/useAuth';

import { loadPreferences, savePreferencesFromTrip } from './services/preferencesApi';
import { parseCurrentRoute, type Route } from './services/sharedTripRouter';
Expand Down Expand Up @@ -102,17 +106,34 @@ export default function App() {
const [preferenceDefaults, setPreferenceDefaults] = useState<Partial<FormData> | null>(null);
const [queModalOpen, setQueModalOpen] = useState(false);
const [notebookOpen, setNotebookOpen] = useState(false);
const [worldSceneOpen, setWorldSceneOpen] = useState(false);
const [antiItineraryForm, setAntiItineraryForm] = useState<FormData | null>(null);
const [portabilityOpen, setPortabilityOpen] = useState(false);
const { user } = useAuth();
void generateAntiItinerary;
Comment on lines 112 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The statement void generateAntiItinerary; is unused dead code and should be removed.

Suggested change
const { user } = useAuth();
void generateAntiItinerary;
const { user } = useAuth();


useEffect(() => {
const storedItinerary = localStorage.getItem(ITINERARY_LS_KEY);
const storedSavedItineraries = localStorage.getItem(SAVED_ITINERARIES_LS_KEY);

const isFixtureItinerary = (it: { destination?: string; overview?: string } | null): boolean => {
if (!it) return false;
return Boolean(
(it.destination && it.destination.includes('[MOCK]')) ||
(it.overview && it.overview.includes('[FIXTURE'))
);
};

if (storedItinerary) {
try {
const parsedItinerary = JSON.parse(storedItinerary);
setItinerary(parsedItinerary);
setView('result');
if (isFixtureItinerary(parsedItinerary)) {
console.warn('[App] Purging cached fixture itinerary; live mode will fetch fresh from Gemini.');
localStorage.removeItem(ITINERARY_LS_KEY);
} else {
setItinerary(parsedItinerary);
setView('result');
}
} catch (e) {
console.error("Failed to parse stored itinerary", e);
localStorage.removeItem(ITINERARY_LS_KEY);
Expand All @@ -121,7 +142,13 @@ export default function App() {

if (storedSavedItineraries) {
try {
setSavedItineraries(JSON.parse(storedSavedItineraries));
const list = JSON.parse(storedSavedItineraries) as Array<{ destination?: string; overview?: string }>;
const cleaned = list.filter((it) => !isFixtureItinerary(it));
if (cleaned.length !== list.length) {
console.warn(`[App] Purging ${list.length - cleaned.length} cached fixture itineraries from saved list.`);
localStorage.setItem(SAVED_ITINERARIES_LS_KEY, JSON.stringify(cleaned));
}
setSavedItineraries(cleaned as ItineraryPlan[]);
} catch (e) {
console.error("Failed to parse saved itineraries", e);
localStorage.removeItem(SAVED_ITINERARIES_LS_KEY);
Expand Down Expand Up @@ -489,7 +516,7 @@ export default function App() {
onSubmit={handleGenerateItinerary}
onBack={() => itinerary ? setView('result') : setView('hero')}
error={error}
initialData={lastFormData ?? (cardPullPrefill as FormData | null) ?? (preferenceDefaults as FormData | null)}
initialData={lastFormData ?? cardPullPrefill ?? preferenceDefaults}
onGoHome={handleGoHome}
/>
</motion.div>
Expand Down Expand Up @@ -526,6 +553,7 @@ export default function App() {
onGoHome={handleGoHome}
isSaved={isSaved || isSharedView}
isExportingPDF={isExportingPDF}
formData={lastFormData}
/>
<div className="max-w-3xl mx-auto px-4 mt-6 space-y-6 mb-10">
<PersonalWorldBadge />
Expand All @@ -546,6 +574,14 @@ export default function App() {
>
✍️ Mơ viết thư cho bạn
</button>
{lastFormData && (
<button
onClick={() => setAntiItineraryForm(lastFormData)}
className="px-4 py-2 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white text-sm font-semibold"
>
🌒 Thử Anti-Itinerary
</button>
)}
</div>
</div>
</motion.div>
Expand Down Expand Up @@ -660,22 +696,34 @@ export default function App() {
<Analytics />
<SpeedInsights />

<div className="fixed top-4 right-4 z-30 flex gap-2">
<button
onClick={() => setQueModalOpen(true)}
className="px-3 py-1.5 text-xs font-medium text-purple-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors"
aria-label="Đường về quê"
>
🏡 Về quê
</button>
<button
onClick={() => setAuthModalOpen(true)}
className="px-3 py-1.5 text-xs font-medium text-teal-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors"
aria-label="Đăng nhập"
>
Đăng nhập
</button>
</div>
{/* Quick-access buttons — hidden on Hero (Hero has its own top-right nav) to prevent overlap */}
{view !== 'hero' && (
<div className="fixed top-4 right-4 z-30 flex gap-2">
<button
onClick={() => setQueModalOpen(true)}
className="px-3 py-1.5 text-xs font-medium text-purple-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors"
aria-label="Đường về quê"
>
🏡 Về quê
</button>
{user && (
<button
onClick={() => setWorldSceneOpen(true)}
className="px-3 py-1.5 text-xs font-medium text-emerald-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors"
aria-label="Thế giới của bạn"
>
🌳 Thế giới
</button>
)}
<button
onClick={() => (user ? setPortabilityOpen(true) : setAuthModalOpen(true))}
className="px-3 py-1.5 text-xs font-medium text-teal-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors"
aria-label={user ? 'Tài khoản' : 'Đăng nhập'}
>
{user ? '⚙️ Tài khoản' : 'Đăng nhập'}
</button>
</div>
)}

{/* Main Content — inline styles ensure visibility even if CSS fails */}
<main
Expand Down Expand Up @@ -720,6 +768,40 @@ export default function App() {
trip={itinerary}
onClose={() => setNotebookOpen(false)}
/>
<PersonalWorldScene open={worldSceneOpen} onClose={() => setWorldSceneOpen(false)} />
<AntiItineraryView
open={antiItineraryForm !== null}
form={antiItineraryForm}
onClose={() => setAntiItineraryForm(null)}
onWantNormalPlan={() => {
const f = antiItineraryForm;
setAntiItineraryForm(null);
if (f) void handleGenerateItinerary(f);
}}
/>
<AnimatePresence>
{portabilityOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
onClick={() => setPortabilityOpen(false)}
>
<div onClick={(e) => e.stopPropagation()} className="w-full max-w-md">
<DataPortabilityPanel />
<div className="text-center mt-3">
<button
onClick={() => setPortabilityOpen(false)}
className="text-slate-400 hover:text-white text-xs"
>
Đóng
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<AuthModal open={authModalOpen} onClose={() => setAuthModalOpen(false)} />

{/* Toast Notification */}
Expand Down
128 changes: 128 additions & 0 deletions components/AntiItineraryView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import type { FormData } from '../types';
import { generateAntiItinerary, type AntiItinerary } from '../services/antiItinerary';

interface AntiItineraryViewProps {
open: boolean;
form: FormData | null;
onClose: () => void;
onWantNormalPlan: () => void;
}

type State = 'loading' | 'ready' | 'error';

export function AntiItineraryView({ open, form, onClose, onWantNormalPlan }: AntiItineraryViewProps) {
const [state, setState] = useState<State>('loading');
const [anti, setAnti] = useState<AntiItinerary | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);

useEffect(() => {
if (!open || !form) return;
setState('loading');
setErrorMsg(null);
setAnti(null);
generateAntiItinerary(form)
.then((value) => {
setAnti(value);
setState('ready');
})
.catch((err: Error) => {
if (err.message === 'RATE_LIMIT_EXCEEDED') {
setErrorMsg('Hôm nay Mơ đã thì thầm đủ. Quay lại ngày mai nhé.');
} else if (err.message === 'BUDGET_EXCEEDED') {
setErrorMsg('Mơ đang nghỉ. Mai mình mơ tiếp nhé.');
} else {
setErrorMsg('Mơ không thì thầm được. Thử lại sau.');
}
setState('error');
});
}, [open, form]);
Comment on lines +20 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the component is unmounted or the open/form props change while generateAntiItinerary is still running, the promise will resolve and attempt to update the state of an unmounted or stale component. Use a cleanup flag to prevent race conditions and state updates on unmounted components.

  useEffect(() => {
    if (!open || !form) return;
    let active = true;
    setState('loading');
    setErrorMsg(null);
    setAnti(null);
    generateAntiItinerary(form)
      .then((value) => {
        if (!active) return;
        setAnti(value);
        setState('ready');
      })
      .catch((err: Error) => {
        if (!active) return;
        if (err.message === 'RATE_LIMIT_EXCEEDED') {
          setErrorMsg('Hôm nay Mơ đã thì thầm đủ. Quay lại ngày mai nhé.');
        } else if (err.message === 'BUDGET_EXCEEDED') {
          setErrorMsg('Mơ đang nghỉ. Mai mình mơ tiếp nhé.');
        } else {
          setErrorMsg('Mơ không thì thầm được. Thử lại sau.');
        }
        setState('error');
      });
    return () => {
      active = false;
    };
  }, [open, form]);


if (!open) return null;

return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-gradient-to-br from-slate-950 via-purple-950 to-slate-950 flex items-center justify-center p-6"
>
<button
onClick={onClose}
aria-label="Đóng"
className="absolute top-4 right-4 px-3 py-1.5 text-xs text-slate-400 hover:text-white border border-white/10 rounded-full"
>
Đóng
</button>

<div className="max-w-xl w-full text-center space-y-8">
<p className="text-purple-300 text-xs uppercase tracking-[0.3em]">Anti-Itinerary</p>

{state === 'loading' && (
<motion.p
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2, repeat: Infinity }}
className="text-slate-400 text-base"
>
Mơ đang thì thầm…
</motion.p>
)}

{state === 'error' && (
<div className="space-y-4">
<p className="text-rose-300">{errorMsg}</p>
<button
onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/15 text-white rounded-xl text-sm"
>
Quay về
</button>
</div>
)}

{state === 'ready' && anti && (
<motion.div
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 1.2, ease: 'easeOut' }}
className="space-y-10"
>
<p className="text-3xl md:text-4xl text-white font-extralight leading-snug italic">
"{anti.vibe}"
</p>
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.3em] text-purple-300">Hướng đi</p>
<p className="text-xl md:text-2xl text-white font-light leading-relaxed">
{anti.direction}
</p>
</div>
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.3em] text-purple-300">Lời thì thầm</p>
<p className="text-base md:text-lg text-slate-300 font-light leading-relaxed">
— {anti.whisper}
</p>
</div>

<div className="pt-6 flex flex-col sm:flex-row gap-3 justify-center">
<button
onClick={onClose}
className="px-5 py-2.5 bg-white/10 hover:bg-white/15 border border-white/10 text-white text-sm rounded-xl"
>
Tôi sẽ đi
</button>
<button
onClick={onWantNormalPlan}
className="px-5 py-2.5 text-slate-400 hover:text-white text-sm"
>
Cho tôi một lịch trình bình thường thay thế
</button>
</div>
</motion.div>
)}
</div>
</motion.div>
</AnimatePresence>
);
}
Loading
Loading