diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index b7ba481..83b42f6 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { STORAGE_KEYS } from "@/lib/storage"; +import { DailyMail } from "@/features/motivation/DailyMail"; export function AppHeader({ done, @@ -61,6 +62,7 @@ export function AppHeader({
{done}/{total} topics
+ + + {open && data && ( +
setOpen(false)} + > +
e.stopPropagation()} + > +
+
+ letter · {data.key} +
+ +
+
+ "{data.quote}" +
+
+ — a note for today +
+
+
+ )} + + ); +} diff --git a/src/features/motivation/MilestoneRibbon.tsx b/src/features/motivation/MilestoneRibbon.tsx new file mode 100644 index 0000000..6553726 --- /dev/null +++ b/src/features/motivation/MilestoneRibbon.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from "react"; +import { gsap } from "gsap"; +import { pickMilestoneQuote } from "@/lib/quotes"; + +export type MilestoneEvent = { + id: string; // unique key to retrigger + section: string; + threshold: 25 | 50 | 75 | 100; +}; + +export function MilestoneRibbon({ event }: { event: MilestoneEvent | null }) { + const [visible, setVisible] = useState(null); + const [quote, setQuote] = useState(""); + const ref = useRef(null); + + useEffect(() => { + if (!event) return; + setQuote(pickMilestoneQuote(event.threshold)); + setVisible(event); + }, [event]); + + useEffect(() => { + if (!visible || !ref.current) return; + const el = ref.current; + gsap.fromTo( + el, + { x: 80, opacity: 0 }, + { x: 0, opacity: 1, duration: 0.7, ease: "power3.out" }, + ); + const t = setTimeout(() => { + gsap.to(el, { + x: 80, + opacity: 0, + duration: 0.6, + ease: "power2.in", + onComplete: () => setVisible(null), + }); + }, 5200); + return () => clearTimeout(t); + }, [visible]); + + if (!visible) return null; + + return ( +
+
+ {visible.threshold === 100 ? "section complete" : `${visible.threshold}% · ${visible.section}`} +
+
+ {quote} +
+
+ ); +} diff --git a/src/features/motivation/PickupBanner.tsx b/src/features/motivation/PickupBanner.tsx new file mode 100644 index 0000000..1e3e4da --- /dev/null +++ b/src/features/motivation/PickupBanner.tsx @@ -0,0 +1,41 @@ +import { useEffect, useRef, useState } from "react"; +import { gsap } from "gsap"; + +export function PickupBanner({ label }: { label: string | null }) { + const [visible, setVisible] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!label) return; + setVisible(true); + }, [label]); + + useEffect(() => { + if (!visible || !ref.current) return; + const el = ref.current; + gsap.fromTo(el, { y: -10, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, ease: "power2.out" }); + const t = setTimeout(() => { + gsap.to(el, { + opacity: 0, + duration: 0.8, + ease: "power2.in", + onComplete: () => setVisible(false), + }); + }, 4200); + return () => clearTimeout(t); + }, [visible]); + + if (!visible || !label) return null; + + return ( +
+
+ picking up where you left off · {label} +
+
+ ); +} diff --git a/src/lib/quotes.ts b/src/lib/quotes.ts new file mode 100644 index 0000000..cbfe93b --- /dev/null +++ b/src/lib/quotes.ts @@ -0,0 +1,70 @@ +// daily quotes + milestone quotes. engineering / aerospace / stoic flavor. + +export const DAILY_QUOTES: string[] = [ + "the engineer's first duty is to the truth, not to the deadline.", + "a problem well stated is a problem half solved.", + "every equation you understand is a small piece of the universe you own.", + "rockets don't care how you feel. show up anyway.", + "the day you stop learning is the day the field leaves you behind.", + "first principles. always.", + "you don't rise to your goals. you fall to your habits.", + "the gap between knowing and doing is closed only by reps.", + "drag is real. so is lift. choose what you generate today.", + "no one ever drowned in sweat.", + "small daily improvements compound into stunning results.", + "the syllabus is finite. your time is too. respect both.", + "discipline is choosing between what you want now and what you want most.", + "if you can't explain it simply, you don't understand it well enough.", + "study like the exam is tomorrow. live like it isn't.", + "the expert in anything was once a beginner who refused to quit.", + "you are not behind. you are exactly where consistency would have put you.", + "the only easy day was yesterday.", + "motivation gets you started. systems keep you going.", + "don't break the chain.", + "an hour of focused work beats a day of distracted study.", + "the calm sea never made a skilled sailor.", + "feynman: the first principle is that you must not fool yourself.", + "von braun: research is what i'm doing when i don't know what i'm doing.", + "kalam: dream is not what you see in sleep. it is what doesn't let you sleep.", + "edison: genius is one percent inspiration, ninety-nine percent perspiration.", + "every topic you skip today will find you in the exam hall.", + "you don't need more time. you need fewer tabs.", + "thrust over drag. effort over excuse.", + "the syllabus shrinks every time you sit down. open it.", +]; + +export const MILESTONE_QUOTES: Record<25 | 50 | 75 | 100, string[]> = { + 25: [ + "a quarter of the way. the takeoff roll is the loudest part.", + "twenty-five percent. momentum is a quiet thing until it isn't.", + "the first quarter is the hardest. you cleared it.", + ], + 50: [ + "halfway. the view from here is the rest of the climb.", + "fifty percent. you are no longer starting. you are continuing.", + "the second half is paid for by the first. keep going.", + ], + 75: [ + "three quarters. the runway is behind you now.", + "seventy-five percent. closer to done than to start.", + "you are in the final stretch. don't decelerate.", + ], + 100: [ + "complete. a section closed is a small forever.", + "one hundred percent. carry this feeling into the next.", + "done. now revise. then move.", + ], +}; + +// pick a deterministic daily quote so the same quote shows all day. +export function getDailyQuote(date = new Date()): { quote: string; key: string } { + const key = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0; + return { quote: DAILY_QUOTES[h % DAILY_QUOTES.length], key }; +} + +export function pickMilestoneQuote(threshold: 25 | 50 | 75 | 100): string { + const arr = MILESTONE_QUOTES[threshold]; + return arr[Math.floor(Math.random() * arr.length)]; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 5b1793c..4b57dc7 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -12,6 +12,9 @@ export const STORAGE_KEYS = { theme: "gate-ae-theme-v1", tourDone: "gate-ae-tour-done-v1", feedbackCooldown: "gate-ae-feedback-cooldown-v1", + milestones: "gate-ae-milestones-v1", + lastTouched: "gate-ae-last-touched-v1", + dailyMailOpened: "gate-ae-daily-mail-v1", } as const; export function loadJSON(key: string, fallback: T): T { diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 5364070..1f4f7cd 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { gsap } from "gsap"; import { syllabus } from "@/lib/syllabus"; -import { topicKey } from "@/lib/syllabus-utils"; +import { sectionStats, topicKey } from "@/lib/syllabus-utils"; import { STORAGE_KEYS, loadJSON } from "@/lib/storage"; import type { Notes, Progress, Resources, Revisions, ViewKey } from "@/types"; @@ -21,11 +21,27 @@ import { LogView } from "@/features/log/LogView"; import { GuideView } from "@/features/guide/GuideView"; import { FeedbackView } from "@/features/feedback/FeedbackView"; import { TourOverlay } from "@/features/tour/TourOverlay"; +import { + MilestoneRibbon, + type MilestoneEvent, +} from "@/features/motivation/MilestoneRibbon"; +import { PickupBanner } from "@/features/motivation/PickupBanner"; export const Route = createFileRoute("/")({ component: Home, }); +type Milestones = Record; +type LastTouched = { view: ViewKey; active: string; ts: number }; + +const THRESHOLDS: Array<0 | 25 | 50 | 75 | 100> = [0, 25, 50, 75, 100]; +function currentThreshold(pct: number): 0 | 25 | 50 | 75 | 100 { + const p = Math.round(pct * 100); + let t: 0 | 25 | 50 | 75 | 100 = 0; + for (const v of THRESHOLDS) if (p >= v) t = v; + return t; +} + function Home() { const [progress, setProgress] = useState({}); const [notes, setNotes] = useState({}); @@ -35,15 +51,46 @@ function Home() { const [view, setView] = useState("syllabus"); const [isHydrated, setIsHydrated] = useState(false); const [tourOpen, setTourOpen] = useState(false); + const [milestoneEvent, setMilestoneEvent] = useState(null); + const [pickupLabel, setPickupLabel] = useState(null); + const milestonesRef = useRef({}); const mainRef = useRef(null); // hydrate from localStorage useEffect(() => { - setProgress(loadJSON(STORAGE_KEYS.progress, {})); + const p = loadJSON(STORAGE_KEYS.progress, {}); + setProgress(p); setNotes(loadJSON(STORAGE_KEYS.notes, {})); const v2 = loadJSON(STORAGE_KEYS.resources, null); setResources(v2 ?? loadJSON(STORAGE_KEYS.resourcesLegacy, {})); setRevisions(loadJSON(STORAGE_KEYS.revise, {})); + + // seed milestones from current progress so we don't fire on first load + const seeded: Milestones = loadJSON(STORAGE_KEYS.milestones, {}); + for (const s of syllabus) { + const st = sectionStats(s, p); + const cur = currentThreshold(st.pct); + if ((seeded[s.id] ?? 0) < cur) seeded[s.id] = cur; + } + milestonesRef.current = seeded; + try { + localStorage.setItem(STORAGE_KEYS.milestones, JSON.stringify(seeded)); + } catch {} + + // last-touched restore + const lt = loadJSON(STORAGE_KEYS.lastTouched, null); + if (lt) { + if (lt.view) setView(lt.view); + if (lt.active) setActive(lt.active); + const stale = Date.now() - lt.ts; + if (stale > 15 * 60 * 1000) { + const sec = syllabus.find((s) => s.id === lt.active); + const label = + lt.view === "syllabus" && sec ? sec.title.toLowerCase() : lt.view; + setPickupLabel(label); + } + } + setIsHydrated(true); }, []); @@ -51,7 +98,7 @@ function Home() { useEffect(() => { if (!isHydrated) return; if (typeof window === "undefined") return; - if (window.innerWidth < 1024) return; // mobile is blocked anyway + if (window.innerWidth < 1024) return; try { const done = localStorage.getItem(STORAGE_KEYS.tourDone); if (!done) { @@ -61,7 +108,7 @@ function Home() { } catch {} }, [isHydrated]); - // persist on change (guarded so first-paint empty state never overwrites real data) + // persist useEffect(() => { if (!isHydrated) return; localStorage.setItem(STORAGE_KEYS.progress, JSON.stringify(progress)); @@ -79,7 +126,47 @@ function Home() { localStorage.setItem(STORAGE_KEYS.revise, JSON.stringify(revisions)); }, [revisions, isHydrated]); - // entrance animation on view/section change + // persist last-touched on view/active change + useEffect(() => { + if (!isHydrated) return; + try { + const payload: LastTouched = { view, active, ts: Date.now() }; + localStorage.setItem(STORAGE_KEYS.lastTouched, JSON.stringify(payload)); + } catch {} + }, [view, active, isHydrated]); + + // milestone detection on progress change + useEffect(() => { + if (!isHydrated) return; + const prev = milestonesRef.current; + const next: Milestones = { ...prev }; + let fired: MilestoneEvent | null = null; + for (const s of syllabus) { + const st = sectionStats(s, progress); + const cur = currentThreshold(st.pct); + const last = prev[s.id] ?? 0; + if (cur > last) { + next[s.id] = cur; + if (cur > 0 && !fired) { + fired = { + id: `${s.id}-${cur}-${Date.now()}`, + section: s.title.toLowerCase(), + threshold: cur as 25 | 50 | 75 | 100, + }; + } + } else if (cur < last) { + // user unchecked back below — reset so they can fire again + next[s.id] = cur; + } + } + milestonesRef.current = next; + try { + localStorage.setItem(STORAGE_KEYS.milestones, JSON.stringify(next)); + } catch {} + if (fired) setMilestoneEvent(fired); + }, [progress, isHydrated]); + + // entrance animation useEffect(() => { const ctx = gsap.context(() => { gsap.to(".fade-in", { @@ -111,6 +198,7 @@ function Home() { pct={overall.pct} onStartTour={() => setTourOpen(true)} /> + {view === "syllabus" && ( @@ -135,6 +223,7 @@ function Home() { + setTourOpen(false)}