From 4b27ec7fb3890f982c58bb38d90508b582a2eca0 Mon Sep 17 00:00:00 2001 From: onlyonehas Date: Mon, 27 Jul 2026 19:29:30 +0100 Subject: [PATCH 1/3] fix: auth state reset, due next calc, sign-up tip, analytics init --- helpers/calculations.ts | 23 +++++------------------ hooks/useCalculations.ts | 15 +++++++++------ lib/firebase-client.ts | 12 +++++++++++- pages/_app.tsx | 28 ++++++++++++++++++++++++++++ pages/index.tsx | 21 +++++++++++++++++++++ 5 files changed, 74 insertions(+), 25 deletions(-) diff --git a/helpers/calculations.ts b/helpers/calculations.ts index 8c6b518d..e935fdb6 100644 --- a/helpers/calculations.ts +++ b/helpers/calculations.ts @@ -49,11 +49,7 @@ export function getAutoCommentedLines( } const info = parseVariableLine(line); - if ( - info && - deductionDates[info.name] && - currentDay >= deductionDates[info.name] - ) { + if (info && deductionDates[info.name] && currentDay >= deductionDates[info.name]) { return `//${line}`; } return line; @@ -71,9 +67,7 @@ export function tryGetValue( if (originalValues[name] !== undefined) return originalValues[name]; const line = inputLines[lineIndex]; if (!line) return undefined; - const content = line.trim().startsWith("//") - ? line.trim().slice(2).trim() - : line.trim(); + const content = line.trim().startsWith("//") ? line.trim().slice(2).trim() : line.trim(); const sep = content.includes("=") ? "=" : ":"; const expr = content.split(sep).slice(1).join(sep).trim(); const num = Number(expr); @@ -98,25 +92,18 @@ export function computeSummary( for (let i = 0; i < inputLines.length; i++) { const line = inputLines[i]; const info = parseVariableLine(line); - if (!info || info.separator !== ":") continue; + if (!info || info.separator !== ":" || info.isCommented) continue; const value = variables[info.name]; if (value !== undefined) { const hasDeduction = deductionDates[info.name] !== undefined; - const isDeducted = - hasDeduction && currentDay >= deductionDates[info.name]; + const isDeducted = hasDeduction && currentDay >= deductionDates[info.name]; if (!isDeducted) { remainingTotal += value; } } - const dueValue = tryGetValue( - inputLines, - variables, - originalValues, - info.name, - i, - ); + const dueValue = tryGetValue(inputLines, variables, originalValues, info.name, i); if (dueValue !== undefined) { dueNextTotal += dueValue; } diff --git a/hooks/useCalculations.ts b/hooks/useCalculations.ts index e04d3858..435d947b 100644 --- a/hooks/useCalculations.ts +++ b/hooks/useCalculations.ts @@ -1,6 +1,6 @@ import { database } from "@/pages/_document"; import { User } from "firebase/auth"; -import { get, off, ref, set, update } from "firebase/database"; +import { get, ref, set, update } from "firebase/database"; import { useEffect, useState } from "react"; import { useCustomAuth } from "./useCustomAuth"; @@ -29,13 +29,16 @@ export const useCalculations = (): UseCalculationsResult => { if (user && database) { setCalculationsRef(ref(database, `users/${user?.uid}/calculations`)); } - return () => { - if (calculationsRef) { - off(calculationsRef); - } - }; }, [user, database]); + useEffect(() => { + if (!user) { + setCalculations(null); + setHasFetchedCalculations(false); + setCalculationsRef(null); + } + }, [user]); + useEffect(() => { if (!hasFetchedCalculations && calculationsRef) { getCalculations(); // Pass the reference here diff --git a/lib/firebase-client.ts b/lib/firebase-client.ts index 554924db..4668f7de 100644 --- a/lib/firebase-client.ts +++ b/lib/firebase-client.ts @@ -1,3 +1,4 @@ +import { Analytics, getAnalytics, isSupported } from "firebase/analytics"; import { FirebaseApp, getApp, getApps, initializeApp } from "firebase/app"; import { Auth, getAuth } from "firebase/auth"; import { Database, getDatabase } from "firebase/database"; @@ -23,6 +24,7 @@ const hasRequiredConfig = !!( let app: FirebaseApp | undefined; let auth: Auth | undefined; let database: Database | undefined; +let analytics: Analytics | undefined; try { if (hasRequiredConfig) { @@ -34,4 +36,12 @@ try { // Firebase unavailable during build/SSR or missing env vars } -export { app, auth, database }; +if (hasRequiredConfig && typeof window !== "undefined") { + isSupported().then((supported) => { + if (supported) { + analytics = getAnalytics(app!); + } + }); +} + +export { app, auth, database, analytics }; diff --git a/pages/_app.tsx b/pages/_app.tsx index c51e46f8..8158ba1a 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,8 +1,36 @@ +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import { analytics } from "@/lib/firebase-client"; +import { logEvent } from "firebase/analytics"; import { DarkModeProvider } from "@/contexts/DarkModeContext"; import type { AppProps } from "next/app"; import "../app/globals.css"; function MyApp({ Component, pageProps }: AppProps) { + const router = useRouter(); + + useEffect(() => { + if (analytics) { + logEvent(analytics, "page_view", { + page_path: window.location.pathname, + page_title: document.title, + }); + } + }, []); + + useEffect(() => { + const handleRouteChange = (url: string) => { + if (analytics) { + logEvent(analytics, "page_view", { + page_path: url, + page_title: document.title, + }); + } + }; + router.events.on("routeChangeComplete", handleRouteChange); + return () => router.events.off("routeChangeComplete", handleRouteChange); + }, [router.events]); + return ( diff --git a/pages/index.tsx b/pages/index.tsx index b1742f57..e4225ae6 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -78,6 +78,15 @@ export default function Index() { } }, [calculations]); + useEffect(() => { + if (!user) { + setInput(initialInput); + setOutput(""); + setDeductionDates({}); + localStorage.removeItem("deductionDates"); + } + }, [user]); + useEffect(() => { if (outputRef.current) { outputRef.current.style.height = "auto"; @@ -531,6 +540,18 @@ export default function Index() { ); })()} + {!user && ( +
+ Sign in to save your notebook to the cloud and access it from anywhere. + +
+ )} + Date: Mon, 27 Jul 2026 20:38:30 +0100 Subject: [PATCH 2/3] fix: replace sign-up banner with toolbar button + fix Quill editor empty on mount --- .opencode/plans/fix-auth-due-analytics.md | 172 +++++++++++++ docs/KINETIC_MOTION.md | 281 ++++++++++++++++++++++ pages/index.tsx | 57 +++-- 3 files changed, 487 insertions(+), 23 deletions(-) create mode 100644 .opencode/plans/fix-auth-due-analytics.md create mode 100644 docs/KINETIC_MOTION.md diff --git a/.opencode/plans/fix-auth-due-analytics.md b/.opencode/plans/fix-auth-due-analytics.md new file mode 100644 index 00000000..a3e6d185 --- /dev/null +++ b/.opencode/plans/fix-auth-due-analytics.md @@ -0,0 +1,172 @@ +# Fix: Auth state, Due Next, Sign-up tip, Analytics + +## Problem Summary + +1. **Due Next shows 850 (incorrect)** — `computeSummary` includes commented line `//comment: 300` +2. **Sign-out state persists** — input/output/deductionDates not cleared when user logs out +3. **No sign-up prompt** — anonymous users never nudged to sign in +4. **No analytics** — `measurementId` configured but `getAnalytics` never called + +--- + +## Changes + +### 1. `helpers/calculations.ts` — Fix Due Next total + +**File:** `helpers/calculations.ts` +**Location:** Line 101, inside `computeSummary` + +Change: + +```ts +if (!info || info.separator !== ":") continue; +``` + +To: + +```ts +if (!info || info.separator !== ":" || info.isCommented) continue; +``` + +This prevents `//comment: 300` and any other commented `:`-separated line from contributing to `remainingTotal` or `dueNextTotal`. + +--- + +### 2. `hooks/useCalculations.ts` — Clear state on logout + +**Change:** Watch `user` in a new `useEffect`. When `user` becomes `null`, reset `calculations` to `null` and `hasFetchedCalculations` to `false` so that a fresh sign-in will re-fetch. + +Add a cleanup effect: + +```ts +useEffect(() => { + if (!user) { + setCalculations(null); + setHasFetchedCalculations(false); + setCalculationsRef(null); + } +}, [user]); +``` + +Also remove the `off(calculationsRef)` cleanup inside the ref-building useEffect (it runs on every render and creates a side-effect in a setter). + +--- + +### 3. `pages/index.tsx` — Reset on logout + sign-up tip + +**A) Reset state on logout** + +Add a new `useEffect` after line 96: + +```ts +useEffect(() => { + if (!user) { + setInput(initialInput); + setOutput(""); + setDeductionDates({}); + localStorage.removeItem("deductionDates"); + } +}, [user]); +``` + +**B) Inline sign-up tip banner** + +Add above the main notebook grid (after the motion.div at the editor panel), when `!user`: + +```tsx +{ + !user && ( +
+ ☁️ Sign in to save your notebook to the cloud and access it from anywhere. + +
+ ); +} +``` + +Note: `toggleSingOutModal` is the modal that shows sign-in/sign-out options, so reusing it is correct. + +--- + +### 4. `lib/firebase-client.ts` — Init Firebase Analytics + +Import and init analytics alongside auth/database: + +```ts +import { Analytics, getAnalytics, isSupported } from "firebase/analytics"; +``` + +Add a variable and init block: + +```ts +let analytics: Analytics | undefined; + +if (hasRequiredConfig && typeof window !== "undefined") { + isSupported().then((supported) => { + if (supported) { + analytics = getAnalytics(app!); + } + }); +} +``` + +Export: + +```ts +export { app, auth, database, analytics }; +``` + +--- + +### 5. `pages/_app.tsx` — Log page views + +Import `analytics` from `@/lib/firebase-client` and `logEvent` from `firebase/analytics`. Use Next.js router to log `page_view` on route changes: + +```tsx +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import { analytics } from "@/lib/firebase-client"; +import { logEvent } from "firebase/analytics"; + +// Inside the component: +const router = useRouter(); + +useEffect(() => { + const handleRouteChange = (url: string) => { + if (analytics) { + logEvent(analytics, "page_view", { page_path: url, page_title: document.title }); + } + }; + router.events.on("routeChangeComplete", handleRouteChange); + return () => router.events.off("routeChangeComplete", handleRouteChange); +}, [router.events]); +``` + +Also log an initial page_view on mount: + +```tsx +useEffect(() => { + if (analytics) { + logEvent(analytics, "page_view", { + page_path: window.location.pathname, + page_title: document.title, + }); + } +}, []); +``` + +--- + +## Verification + +| Check | Command | +| ----- | ---------------------------------- | +| Build | `npm run build` | +| Lint | `npm run lint` | +| Tests | `npm test` (should still be 41/41) | +| Audit | `npm run audit` | diff --git a/docs/KINETIC_MOTION.md b/docs/KINETIC_MOTION.md new file mode 100644 index 00000000..bb71b41d --- /dev/null +++ b/docs/KINETIC_MOTION.md @@ -0,0 +1,281 @@ +# Kinetic Motion Guide — Instant Calc + +Kinetic motion is what makes a static page feel alive — micro-interactions, transitions, and animations that respond to the user. This guide covers everything already in place and how to push it further. + +--- + +## Existing Motion Stack + +| Technique | Where | What it does | +| ------------------------ | --------------------------------------------------------- | --------------------------------------------------------------------------- | +| **Framer Motion** | `about.tsx` — 8 `` elements + `AnimatePresence` | Typewriter text, hero scale-in, spring-button variants, scroll-down chevron | +| **Framer Motion** | `index.tsx` — 4 `` elements | Staggered entrance of title → notebook bar → summary bar → editor panel | +| **CSS `@keyframes`** | `globals.css` — `animate-gradient` | 8-second gradient sweep on hero heading and typewriter text | +| **CSS `@keyframes`** | `Home.module.css` — `.title` | 2-second gradient sweep on the Home title | +| **CSS transitions** | `Dark.css` — `.dark` / `.light` / `.tdnn` / `.moon` | Dark/light mode color swaps (200–500 ms) | +| **Tailwind transitions** | `about.tsx`, `index.tsx`, `Switch.tsx` | Button hovers, toggle switch dot, calendar icon opacity | +| **Scroll-sync** | `index.tsx` — `handleInputScroll` / `handleOutputScroll` | Input and output panels scroll in lockstep | + +--- + +## Architecture & How It Works + +### 1. Framer Motion — Declarative Animation + +Framer Motion wraps React elements in ``, ``, etc. Every motion component accepts: + +```tsx + +``` + +**Spring physics** (used in `buttonVariants`): + +```tsx +visible: { + opacity: 1, y: 0, + transition: { type: "spring", stiffness: 300, damping: 24 }, +} +``` + +Springs feel organic — stiffness controls speed, damping controls bounciness. Higher stiffness = snappier. + +**Variants** group named states and can cascade to children: + +```tsx +const variants = { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 24 } }, + hover: { scale: 1.05 }, +}; +``` + +Used with `initial="hidden"` `animate="visible"` `whileHover="hover"`. + +**`AnimatePresence`** (typewriter word swap): + +```tsx + + + {displayedText} + + +``` + +`mode="wait"` waits for exit to finish before entering. The `key` change triggers the exit/enter cycle. + +### 2. CSS `@keyframes` — The Gradient Sweep + +```css +@keyframes gradient { + 0%, + 100% { + background-size: 200% 200%; + background-position: left center; + } + 50% { + background-size: 200% 200%; + background-position: right center; + } +} +.animate-gradient { + animation: gradient 8s linear infinite; +} +``` + +A background gradient larger than the element (200%) slides left-to-right over 8 seconds. Applied to text with `background-clip: text` for the colour-shifting heading. + +### 3. CSS Transitions — Dark Mode + +```css +.dark { + transition: all 0.2s ease-in-out; +} +.light { + transition: all 0.2s ease-in-out; +} +.tdnn { + transition: all 500ms ease-in-out; +} +``` + +When a class is toggled (`.dark` ↔ `.light`), every animatable property transitions over 200 ms. The toggle knob uses `transition: all 400ms ease-in-out` to slide smoothly. + +### 4. Tailwind Utility Transitions + +```tsx +className = "transition duration-300 ease-in-out hover:bg-blue-600"; +``` + +Lightweight, no JS runtime cost. Best for colour shifts, opacity fades, and simple hover effects. + +### 5. Typewriter Effect (Custom Hook) + +Not a library — pure `setTimeout` in `useEffect`: + +- Types one character at a time by slicing from the source string +- Deletes one character at a time by slicing from the displayed string +- Swaps between two words (`"Instant Calculation"` / `"Instant Feedback"`) once the cycle completes + +The hook signature: + +```ts +const displayedText = useTypewriter(text, speed, isDeleting, index, setIndex); +``` + +--- + +## How to Add More Kinetic Motion + +### A. Scroll-Triggered Reveals (Intersection Observer) + +Add Framer Motion's `whileInView` for elements that animate when they scroll into the viewport: + +```tsx + +``` + +- `once: true` — animate only the first time +- `margin: "-100px"` — trigger 100px before the element enters the viewport + +### B. Staggered Children (List Reveal) + +Wrap a container and its children with variants: + +```tsx +const container = { + hidden: {}, + visible: { transition: { staggerChildren: 0.08 } }, +}; +const item = { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0 }, +}; + + + {items.map((i) => ( + + {i} + + ))} +; +``` + +Each child delays 80 ms after the previous one — a cascading reveal. + +### C. Hover / Tap Micro-Interactions + +Small feedback on interactive elements: + +```tsx + +``` + +Use `whileTap` to give a "pressed" feel. Combine with a subtle colour transition using Tailwind. + +### D. page Transitions (AnimatePresence + Layout) + +Wrap `Component` in `_app.tsx` with `` and animate the page wrapper: + +```tsx +// _app.tsx + + + + + +``` + +The `key` prop (current route) tells AnimatePresence when to swap pages. `exit` defines how the old page leaves. + +### E. Gradient Animation on Panels + +Add a subtle animated gradient to the editor background: + +```css +@keyframes panelShimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} +.editor-panel { + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.03) 50%, + transparent 100% + ); + background-size: 200% 100%; + animation: panelShimmer 6s ease-in-out infinite; +} +``` + +Best done on a pseudo-element so it doesn't interfere with content. + +### F. Layout Animations (Framer Motion `layout` prop) + +Add `layout` to elements that reflow so Framer Motion interpolates the position change: + +```tsx + + {items.map((item) => ( + + {item.name} + + ))} + +``` + +When items are added/removed/sorted, they animate to their new position instead of snapping. + +--- + +## Performance Considerations + +| Technique | Cost | Notes | +| --------------------------------- | --------------- | --------------------------------------------------------------------------- | +| CSS `transition` / `@keyframes` |  🟢 Free | Composited on the GPU if using `opacity` / `transform` | +| Tailwind hover transitions |  🟢 Free | Same as raw CSS | +| Framer Motion `initial`/`animate` |  🟡 Low | Offloaded to `requestAnimationFrame` | +| Framer Motion `AnimatePresence` |  🟡 Low | Only expensive if many children unmount simultaneously | +| Scroll-sync event listeners |  🟡 Medium | Throttle or debounce if needed | +| CSS gradient animations |  🟠 Medium | Causes repaints — limit to small elements or use `transform: translateZ(0)` | +| Framer Motion `layout` |  🔴 Higher | Triggers layout calculations; avoid on large lists | +| Typewriter setTimeout |  🟢 Free | Only one timer active at a time | + +**Golden rule:** Animate only `opacity` and `transform` where possible — they run on the compositor thread and don't trigger layout or paint. + +--- + +## Quick Wins (High Impact, Low Effort) + +1. **`whileInView` on service sections** — Fade in each feature card as the user scrolls +2. **`whileTap` on all buttons** — Instant tactile feedback for every clickable element +3. **Page transition** — `AnimatePresence` in `_app.tsx` for route-level fades +4. **Staggered list** — Cascade the feature list items in the about page +5. **Smooth number counter** — Animate the running totals in the output panel from 0 to their value on change +6. **Shimmer on the ruled lines** — A subtle animated gradient overlay on `.ql-editor` that adds depth without distracting diff --git a/pages/index.tsx b/pages/index.tsx index e4225ae6..5ba83eb1 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -13,7 +13,7 @@ import { database } from "@/pages/_document"; import { User } from "firebase/auth"; import { get, ref, set } from "firebase/database"; import { motion } from "framer-motion"; -import { Calendar, Edit2, Moon, Save, Sun, Trash2 } from "lucide-react"; +import { Calendar, CloudOff, Edit2, Moon, Save, Sun, Trash2 } from "lucide-react"; import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import "tailwindcss/tailwind.css"; @@ -166,8 +166,11 @@ export default function Index() { const handleQuillChange = useCallback( (_value: string, _delta: any, source: string, _editor: any) => { + if (_editor && !quillEditorRef.current) { + quillEditorRef.current = _editor; + } if (source !== "user") return; - const editor = getQuillEditor(); + const editor = _editor || getQuillEditor(); if (!editor) return; attachQuillScroll(); const text = (editor.getText() || "").replace(/\n$/, ""); @@ -180,14 +183,23 @@ export default function Index() { ); useEffect(() => { - const editor = getQuillEditor(); - if (!editor) return; - const currentText = (editor.getText() || "").replace(/\n$/, ""); - if (currentText !== input) { - editor.setText(input || ""); - } - requestAnimationFrame(() => highlightSyntax(editor)); - }, [input, highlightSyntax, getQuillEditor]); + let rafId: number; + const trySync = () => { + const editor = getQuillEditor(); + if (!editor) { + rafId = requestAnimationFrame(trySync); + return; + } + const currentText = (editor.getText() || "").replace(/\n$/, ""); + if (currentText !== input) { + editor.setText(input || ""); + } + requestAnimationFrame(() => highlightSyntax(editor)); + attachQuillScroll(); + }; + trySync(); + return () => cancelAnimationFrame(rafId); + }, [input, highlightSyntax, getQuillEditor, attachQuillScroll]); useEffect(() => { attachQuillScroll(); @@ -457,7 +469,18 @@ export default function Index() { )} - {user && ( + {!user ? ( + + ) : ( - - )} - Date: Mon, 27 Jul 2026 20:50:39 +0100 Subject: [PATCH 3/3] fix: sign-in button, feature discoverability, toggle wording, mobile wrapping --- pages/index.tsx | 38 ++++++++++++++++++++++++++------------ styles/Dark.css | 14 ++++++++++++-- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/pages/index.tsx b/pages/index.tsx index 5ba83eb1..3996c25e 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -9,8 +9,8 @@ import { VariableMap } from "@/helpers/sharedTypes"; import { useCalculations } from "@/hooks/useCalculations"; import { useCustomAuth } from "@/hooks/useCustomAuth"; import "@/styles/Dark.css"; -import { database } from "@/pages/_document"; -import { User } from "firebase/auth"; +import { app, database } from "@/pages/_document"; +import { GoogleAuthProvider, User, getAuth, signInWithPopup } from "firebase/auth"; import { get, ref, set } from "firebase/database"; import { motion } from "framer-motion"; import { Calendar, CloudOff, Edit2, Moon, Save, Sun, Trash2 } from "lucide-react"; @@ -101,6 +101,7 @@ export default function Index() { useEffect(() => { if (isEditingName && nameInputRef.current) { nameInputRef.current.focus(); + nameInputRef.current.select(); } }, [isEditingName]); @@ -252,6 +253,16 @@ export default function Index() { tempPrev: 0, }; + const signInWithGoogle = async () => { + try { + const auth = getAuth(app); + const provider = new GoogleAuthProvider(); + await signInWithPopup(auth, provider); + } catch (error) { + console.error(error); + } + }; + const saveToDatabase = () => { if (user && input && output) { if (input !== initialInput && output !== null) { @@ -384,7 +395,7 @@ export default function Index() { setNotebookName(event.target.value); }; - const handleNotebookNameDoubleClick = () => { + const handleNotebookNameClick = () => { setIsEditingName(true); }; @@ -451,11 +462,14 @@ export default function Index() { /> ) : (

{notebookName} - + + + rename +

)}
@@ -471,7 +485,7 @@ export default function Index() { {!user ? (
@@ -606,7 +620,7 @@ export default function Index() { }} > ); diff --git a/styles/Dark.css b/styles/Dark.css index 9d51cd00..1b12916a 100644 --- a/styles/Dark.css +++ b/styles/Dark.css @@ -95,7 +95,7 @@ border: none; background: transparent; overflow-y: auto; - overflow-x: hidden; + overflow-x: auto; } .quill-editor .ql-editor { padding: 8px 16px 8px 16px; @@ -104,7 +104,17 @@ height: auto; outline: none; overflow: hidden; - background-image: repeating-linear-gradient(transparent, transparent 47px, #999 47px, #999 48px, transparent 48px, transparent 95px, #999 95px, #999 96px); + white-space: pre; + background-image: repeating-linear-gradient( + transparent, + transparent 47px, + #999 47px, + #999 48px, + transparent 48px, + transparent 95px, + #999 95px, + #999 96px + ); } .quill-editor .ql-editor p { margin: 0;