Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions src/components/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -61,6 +62,7 @@ export function AppHeader({
<div className="mono text-xs text-[var(--muted)]">
{done}/{total} topics
</div>
<DailyMail />
<button
data-tour="tour-btn"
onClick={onStartTour}
Expand Down
79 changes: 79 additions & 0 deletions src/features/motivation/DailyMail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { getDailyQuote } from "@/lib/quotes";
import { STORAGE_KEYS } from "@/lib/storage";

export function DailyMail() {
const [open, setOpen] = useState(false);
const [unread, setUnread] = useState(false);
const [data, setData] = useState<{ quote: string; key: string } | null>(null);

useEffect(() => {
if (typeof window === "undefined") return;
const d = getDailyQuote();
setData(d);
try {
const last = localStorage.getItem(STORAGE_KEYS.dailyMailOpened);
setUnread(last !== d.key);
} catch {
setUnread(true);
}
}, []);

const openMail = () => {
setOpen(true);
if (data) {
try {
localStorage.setItem(STORAGE_KEYS.dailyMailOpened, data.key);
} catch {}
setUnread(false);
}
};

return (
<>
<button
onClick={openMail}
title={unread ? "a letter arrived" : "today's letter"}
className="relative mono text-xs text-[var(--muted)] hover:text-[var(--fg)] transition-colors duration-200 px-[8px] py-[2px] border"
>
mail
{unread && (
<span
className="absolute -top-1 -right-1 w-1.5 h-1.5 rounded-full bg-[var(--accent)] animate-pulse"
/>
)}
</button>

{open && data && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg)]/80 backdrop-blur-sm fade-in"
style={{ opacity: 1, transform: "none" }}
onClick={() => setOpen(false)}
>
<div
className="max-w-lg w-[90%] border border-[var(--line)] bg-[var(--bg)] p-8 md:p-10"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-baseline justify-between mb-6">
<div className="mono text-[10px] tracking-widest text-[var(--faint)] uppercase">
letter · {data.key}
</div>
<button
onClick={() => setOpen(false)}
className="mono text-xs text-[var(--muted)] hover:text-[var(--fg)]"
>
close
</button>
</div>
<div className="serif italic text-2xl md:text-3xl lowercase leading-snug text-[var(--fg)]">
"{data.quote}"
</div>
<div className="mt-6 mono text-[10px] tracking-widest text-[var(--faint)] uppercase">
— a note for today
</div>
</div>
</div>
)}
</>
);
}
58 changes: 58 additions & 0 deletions src/features/motivation/MilestoneRibbon.tsx
Original file line number Diff line number Diff line change
@@ -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<MilestoneEvent | null>(null);
const [quote, setQuote] = useState<string>("");
const ref = useRef<HTMLDivElement>(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 (
<div
ref={ref}
className="fixed bottom-8 right-6 md:right-10 z-40 max-w-sm border border-[var(--line)] bg-[var(--bg)] px-5 py-4 shadow-lg"
style={{ opacity: 0 }}
>
<div className="mono text-[10px] tracking-widest text-[var(--faint)] uppercase">
{visible.threshold === 100 ? "section complete" : `${visible.threshold}% · ${visible.section}`}
</div>
<div className="serif italic text-lg lowercase mt-2 leading-snug text-[var(--fg)]">
{quote}
</div>
</div>
);
}
41 changes: 41 additions & 0 deletions src/features/motivation/PickupBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
className="px-6 md:px-10 -mt-2 mb-2"
style={{ opacity: 0 }}
>
<div className="serif italic text-sm text-[var(--muted)] lowercase">
picking up where you left off · {label}
</div>
</div>
);
}
70 changes: 70 additions & 0 deletions src/lib/quotes.ts
Original file line number Diff line number Diff line change
@@ -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)];
}
3 changes: 3 additions & 0 deletions src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(key: string, fallback: T): T {
Expand Down
Loading