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/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..3996c25e 100644
--- a/pages/index.tsx
+++ b/pages/index.tsx
@@ -9,11 +9,11 @@ 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, 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";
@@ -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";
@@ -92,6 +101,7 @@ export default function Index() {
useEffect(() => {
if (isEditingName && nameInputRef.current) {
nameInputRef.current.focus();
+ nameInputRef.current.select();
}
}, [isEditingName]);
@@ -157,8 +167,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$/, "");
@@ -171,14 +184,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();
@@ -231,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) {
@@ -363,7 +395,7 @@ export default function Index() {
setNotebookName(event.target.value);
};
- const handleNotebookNameDoubleClick = () => {
+ const handleNotebookNameClick = () => {
setIsEditingName(true);
};
@@ -430,11 +462,14 @@ export default function Index() {
/>
) : (