diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index a18f9d1..09fa510 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -47,6 +47,9 @@ jobs: npx cap add android fi + - name: Patch Android for Health Connect + run: node scripts/patch-android.mjs + - name: Generate app icon & splash run: npx capacitor-assets generate --android || echo "asset generation skipped" diff --git a/.gitignore b/.gitignore index 99221a3..c7b249a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,9 @@ dist android .gradle local.properties +# Stable signing key is intentionally committed so APK updates install in place. *.keystore +!vintly-debug.keystore # Env .env diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..14c5894 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,34 @@ +rules_version = '2'; +// Vintly Firestore security rules. +// Paste these into: Firebase Console → Firestore Database → Rules → Publish. +// They replace insecure "test mode" (which expires) with proper protection: +// only signed-in users can read/write, and chats are private to their members. + +service cloud.firestore { + match /databases/{database}/documents { + + // Public user profiles — anyone signed in can look up a username to start a chat, + // but you can only create/edit your own profile document. + match /users/{uid} { + allow read: if request.auth != null; + allow create, update: if request.auth != null && request.auth.uid == uid; + } + + // Conversations — only the members listed on the conversation can access it. + match /conversations/{cid} { + allow read, write: if request.auth != null + && request.auth.uid in resource.data.members; + allow create: if request.auth != null + && request.auth.uid in request.resource.data.members; + + // Messages inside a conversation — restricted to that conversation's members. + // read/create/update (reactions) for any member; delete only by the sender. + match /messages/{mid} { + allow read, create, update: if request.auth != null + && request.auth.uid in get(/databases/$(database)/documents/conversations/$(cid)).data.members; + allow delete: if request.auth != null + && resource.data.from == request.auth.uid; + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 335c28d..35d72b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@capacitor/motion": "^6.0.0", "@capacitor/preferences": "^6.0.2", "@capacitor/status-bar": "^6.0.1", + "capacitor-health": "0.0.14", "date-fns": "^3.6.0", "firebase": "^10.13.2", "lucide-react": "^0.439.0", @@ -3101,6 +3102,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/capacitor-health": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/capacitor-health/-/capacitor-health-0.0.14.tgz", + "integrity": "sha512-N/FO8mro9Ng39+8Z2Jq/bQp02KmbIj4aAOtgWXvhHgcdvsLtOJ3c9fDZlghQoZO6Y1f3pQqMylUb/yA91Yjtzg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^6.0.0" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", diff --git a/package.json b/package.json index d7041cb..4420065 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@capacitor/motion": "^6.0.0", "@capacitor/preferences": "^6.0.2", "@capacitor/status-bar": "^6.0.1", + "capacitor-health": "0.0.14", "date-fns": "^3.6.0", "firebase": "^10.13.2", "lucide-react": "^0.439.0", diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..c800cd2 Binary files /dev/null and b/public/logo.png differ diff --git a/resources/icon-background.png b/resources/icon-background.png index 99dd8a5..dcfb65d 100644 Binary files a/resources/icon-background.png and b/resources/icon-background.png differ diff --git a/resources/icon-foreground.png b/resources/icon-foreground.png index 5eb3ebd..c800cd2 100644 Binary files a/resources/icon-foreground.png and b/resources/icon-foreground.png differ diff --git a/resources/icon.png b/resources/icon.png index 99c4174..c800cd2 100644 Binary files a/resources/icon.png and b/resources/icon.png differ diff --git a/resources/splash.png b/resources/splash.png index bf8a530..fdb9b55 100644 Binary files a/resources/splash.png and b/resources/splash.png differ diff --git a/scripts/patch-android.mjs b/scripts/patch-android.mjs new file mode 100644 index 0000000..bed5ce1 --- /dev/null +++ b/scripts/patch-android.mjs @@ -0,0 +1,96 @@ +// Patches the CI-generated Android project for Health Connect support: +// • adds Health Connect , health read permissions, and the +// permissions-rationale activity to AndroidManifest.xml +// • bumps minSdkVersion to 26 (required by androidx.health.connect) +// Safe to run repeatedly (idempotent). +import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs' + +const manifestPath = 'android/app/src/main/AndroidManifest.xml' +const variablesPath = 'android/variables.gradle' +const appGradlePath = 'android/app/build.gradle' +const keystoreSrc = 'vintly-debug.keystore' +const keystoreDst = 'android/app/vintly-debug.keystore' + +const PERMS_AND_QUERIES = ` + + + + + +` + +const RATIONALE_ACTIVITY = ` + + + + + + + + + + + + +` + +function patchManifest() { + if (!existsSync(manifestPath)) { + console.log('AndroidManifest.xml not found, skipping') + return + } + let m = readFileSync(manifestPath, 'utf8') + if (m.includes('android.permission.health.READ_STEPS')) { + console.log('Manifest already patched') + return + } + // Insert rationale activities before + m = m.replace('', `${RATIONALE_ACTIVITY} `) + // Insert permissions + queries before + m = m.replace('', `${PERMS_AND_QUERIES}`) + writeFileSync(manifestPath, m) + console.log('Patched AndroidManifest.xml for Health Connect') +} + +function patchMinSdk() { + if (!existsSync(variablesPath)) { + console.log('variables.gradle not found, skipping minSdk bump') + return + } + let v = readFileSync(variablesPath, 'utf8') + v = v.replace(/minSdkVersion\s*=\s*\d+/, 'minSdkVersion = 26') + writeFileSync(variablesPath, v) + console.log('Set minSdkVersion = 26') +} + +// Use a committed, stable signing key so APK updates install over the old app +// (no uninstall needed). Applies to the debug build type automatically. +function patchSigning() { + if (!existsSync(appGradlePath) || !existsSync(keystoreSrc)) { + console.log('app build.gradle or keystore missing, skipping signing patch') + return + } + copyFileSync(keystoreSrc, keystoreDst) + let g = readFileSync(appGradlePath, 'utf8') + if (g.includes('vintly-debug.keystore')) { console.log('signing already patched'); return } + const block = ` signingConfigs { + debug { + storeFile file('vintly-debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes {` + g = g.replace(' buildTypes {', block) + writeFileSync(appGradlePath, g) + console.log('Patched app/build.gradle with stable debug signing') +} + +patchManifest() +patchMinSdk() +patchSigning() diff --git a/src/App.tsx b/src/App.tsx index 41cda74..83c0aa3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,22 +5,38 @@ import BottomNav from './components/BottomNav' import Home from './screens/Home' import Tasks from './screens/Tasks' import Notes from './screens/Notes' +import NoteEditor from './screens/NoteEditor' +import Bin from './screens/Bin' import CalendarScreen from './screens/Calendar' import ChatList from './screens/ChatList' import ChatRoom from './screens/ChatRoom' import Fitness from './screens/Fitness' import Reminders from './screens/Reminders' +import Games from './screens/Games' +import Weather from './screens/Weather' +import QuickMath from './games/QuickMath' +import MemoryMatch from './games/MemoryMatch' +import Snake from './games/Snake' +import CarRace from './games/CarRace' import Profile from './screens/Profile' import Settings from './screens/Settings' import Auth from './screens/Auth' import { ensureNotificationPermission } from './lib/notifications' +import { syncEngagementNudges } from './lib/engagement' +import { startAlarmWatcher } from './lib/alarm' +import AlarmRing from './components/AlarmRing' +import { useStore } from './lib/store' export default function App() { const loc = useLocation() - const hideNav = loc.pathname.startsWith('/chat/') || loc.pathname === '/auth' + const hideNav = loc.pathname.startsWith('/chat/') || loc.pathname.startsWith('/note/') || loc.pathname.startsWith('/games/') || loc.pathname === '/auth' useEffect(() => { - ensureNotificationPermission() + ensureNotificationPermission().then(() => { + // (Re)schedule daily motivational nudges + streak reminders. + syncEngagementNudges(useStore.getState().settings) + }) + startAlarmWatcher() if (Capacitor.isNativePlatform()) { import('@capacitor/status-bar') .then(({ StatusBar, Style }) => StatusBar.setStyle({ style: Style.Dark })) @@ -34,17 +50,26 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> {!hideNav &&
} {!hideNav && } +
) } diff --git a/src/components/AlarmRing.tsx b/src/components/AlarmRing.tsx new file mode 100644 index 0000000..024f492 --- /dev/null +++ b/src/components/AlarmRing.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react' +import { AlarmClock, BellOff, Clock } from 'lucide-react' +import { onAlarmRing, stopAlarm, ringingTitle } from '../lib/alarm' +import { useStore } from '../lib/store' +import { scheduleReminder } from '../lib/notifications' + +// Full-screen "ringing" alarm shown when an alarm reminder is due (app open). +export default function AlarmRing() { + const [id, setId] = useState(null) + const { reminders, addReminder } = useStore() + + useEffect(() => onAlarmRing(setId), []) + if (!id) return null + const title = ringingTitle() + + function dismiss() { + useStore.setState((s) => ({ reminders: s.reminders.map((r) => (r.id === id ? { ...r, done: true } : r)) })) + stopAlarm() + } + async function snooze() { + const at = Date.now() + 5 * 60000 + const r = reminders.find((x) => x.id === id) + const newId = Math.random().toString(36).slice(2) + const notifId = await scheduleReminder({ title: '⏰ ' + (r?.title || 'Alarm'), body: 'Snoozed reminder', at: new Date(at) }) + addReminder({ id: newId, title: r?.title || 'Alarm', at, notifId, repeat: 'none', done: false, alarm: true }) + stopAlarm() + } + + return ( +
+
+ +
+

Alarm

+

{title}

+

{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}

+ +
+ + +
+
+ ) +} diff --git a/src/components/ui.tsx b/src/components/ui.tsx index dc619b4..436dc15 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -84,6 +84,19 @@ export function Textarea(props: React.TextareaHTMLAttributes + {isImg ? : value} + + ) +} + export function EmptyState({ icon, title, hint }: { icon: React.ReactNode; title: string; hint?: string }) { return (
diff --git a/src/games/CarRace.tsx b/src/games/CarRace.tsx new file mode 100644 index 0000000..c737c93 --- /dev/null +++ b/src/games/CarRace.tsx @@ -0,0 +1,174 @@ +import { useEffect, useRef, useState } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useStore } from '../lib/store' +import GameShell from './GameShell' +import { Button } from '../components/ui' + +const KEY = 'car' +const W = 360, H = 560 +const LANES = 3 +const laneX = (i: number) => 40 + i * ((W - 80) / LANES) + ((W - 80) / LANES) / 2 + +export default function CarRace() { + const { bestScores, setBest, awardPoints, touchStreak } = useStore() + const canvasRef = useRef(null) + const [score, setScore] = useState(0) + const [over, setOver] = useState(false) + const [running, setRunning] = useState(false) + const s = useRef(null) + const move = useRef(0) // -1 left, 1 right, 0 none + + function reset() { + s.current = { lane: 1, x: laneX(1), road: 0, speed: 5, enemies: [] as any[], coins: [] as any[], spawn: 0, score: 0, dead: false } + setScore(0); setOver(false) + } + useEffect(() => { reset() }, []) + + function steer(dir: number) { + if (!running) { setRunning(true); return } + const st = s.current + st.lane = Math.max(0, Math.min(LANES - 1, st.lane + dir)) + } + + useEffect(() => { + if (!running || over) return + const ctx = canvasRef.current!.getContext('2d')! + let raf = 0 + const brand = (getComputedStyle(document.documentElement).getPropertyValue('--brand').trim()) || '124 92 255' + + function carSprite(x: number, y: number, color: string, w = 46, h = 80) { + ctx.save() + ctx.translate(x, y) + // shadow + ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.ellipse(0, h / 2 - 4, w / 2, 8, 0, 0, 7); ctx.fill() + // body + const g = ctx.createLinearGradient(-w / 2, 0, w / 2, 0) + g.addColorStop(0, color); g.addColorStop(0.5, '#ffffff22'); g.addColorStop(1, color) + ctx.fillStyle = color + rr(ctx, -w / 2, -h / 2, w, h, 12); ctx.fill() + ctx.fillStyle = g; rr(ctx, -w / 2, -h / 2, w, h, 12); ctx.fill() + // windows + ctx.fillStyle = 'rgba(10,15,30,0.85)' + rr(ctx, -w / 2 + 7, -h / 2 + 12, w - 14, 18, 6); ctx.fill() + rr(ctx, -w / 2 + 7, 6, w - 14, 20, 6); ctx.fill() + // wheels + ctx.fillStyle = '#111' + ctx.fillRect(-w / 2 - 3, -h / 2 + 14, 5, 18) + ctx.fillRect(w / 2 - 2, -h / 2 + 14, 5, 18) + ctx.fillRect(-w / 2 - 3, h / 2 - 32, 5, 18) + ctx.fillRect(w / 2 - 2, h / 2 - 32, 5, 18) + ctx.restore() + } + + const loop = () => { + const st = s.current + // smooth lane glide + st.x += (laneX(st.lane) - st.x) * 0.25 + st.road = (st.road + st.speed) % 80 + st.speed += 0.0025 + st.score += 0.05 + st.spawn -= st.speed + if (st.spawn <= 0) { + st.spawn = 200 + Math.random() * 120 + const lane = Math.floor(Math.random() * LANES) + st.enemies.push({ lane, x: laneX(lane), y: -90, color: ['#f43f5e', '#3b82f6', '#22c55e', '#f59e0b'][Math.floor(Math.random() * 4)] }) + if (Math.random() < 0.6) { const cl = Math.floor(Math.random() * LANES); st.coins.push({ x: laneX(cl), y: -180 }) } + } + st.enemies.forEach((e: any) => (e.y += st.speed)) + st.coins.forEach((c: any) => (c.y += st.speed)) + st.enemies = st.enemies.filter((e: any) => e.y < H + 90) + st.coins = st.coins.filter((c: any) => c.y < H + 30 && !c.got) + + const py = H - 70 + for (const e of st.enemies) { + if (Math.abs(e.x - st.x) < 44 && Math.abs(e.y - py) < 76) st.dead = true + } + for (const c of st.coins) { + if (!c.got && Math.abs(c.x - st.x) < 30 && Math.abs(c.y - py) < 40) { c.got = true; st.score += 10 } + } + + // draw road + ctx.fillStyle = '#0c1018'; ctx.fillRect(0, 0, W, H) + ctx.fillStyle = '#1a2030'; ctx.fillRect(30, 0, W - 60, H) // tarmac + // edges + ctx.fillStyle = 'rgb(' + brand + ')'; ctx.fillRect(26, 0, 4, H); ctx.fillRect(W - 30, 0, 4, H) + // lane dashes + ctx.fillStyle = 'rgba(255,255,255,0.5)' + for (let l = 1; l < LANES; l++) { + const lx = 40 + l * ((W - 80) / LANES) - 2 + for (let y = -80 + st.road; y < H; y += 80) ctx.fillRect(lx, y, 4, 40) + } + // coins + for (const c of st.coins) { ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(c.x, c.y, 11, 0, 7); ctx.fill(); ctx.fillStyle = '#b45309'; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('$', c.x, c.y + 4) } + // enemies + player + for (const e of st.enemies) carSprite(e.x, e.y, e.color) + carSprite(st.x, py, 'rgb(' + brand + ')') + + setScore(Math.floor(st.score)) + if (st.dead) { setOver(true); setRunning(false); return } + raf = requestAnimationFrame(loop) + } + raf = requestAnimationFrame(loop) + return () => cancelAnimationFrame(raf) + }, [running, over]) + + useEffect(() => { + if (over) { setBest(KEY, score); if (score > 0) { awardPoints(Math.ceil(score / 8)); touchStreak() } } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [over]) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'ArrowLeft') steer(-1); if (e.key === 'ArrowRight') steer(1) } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }) + + const touchStart = useRef(null) + function restart() { reset(); setRunning(true) } + + return ( + +
(touchStart.current = e.touches[0].clientX)} + onTouchEnd={(e) => { + if (touchStart.current == null) return + const dx = e.changedTouches[0].clientX - touchStart.current + if (Math.abs(dx) > 24) steer(dx > 0 ? 1 : -1) + else if (!running) setRunning(true) + touchStart.current = null + }} + > + + {!running && !over && ( +
+

🏎️

+

Swipe or use arrows to switch lanes

+ +
+ )} + {over && ( +
+

💥 Crashed!

+

Score {score} · +{Math.ceil(score / 8)} pts

+ +
+ )} +
+
+ + +
+
+ ) +} + +function rr(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.arcTo(x + w, y, x + w, y + h, r) + ctx.arcTo(x + w, y + h, x, y + h, r) + ctx.arcTo(x, y + h, x, y, r) + ctx.arcTo(x, y, x + w, y, r) + ctx.closePath() +} diff --git a/src/games/GameShell.tsx b/src/games/GameShell.tsx new file mode 100644 index 0000000..43d1649 --- /dev/null +++ b/src/games/GameShell.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' +import { ArrowLeft, RotateCcw, Trophy } from 'lucide-react' + +export default function GameShell({ + title, + best, + score, + onRestart, + children, + footer, +}: { + title: string + best?: number + score?: number + onRestart?: () => void + children: ReactNode + footer?: ReactNode +}) { + const nav = useNavigate() + return ( +
+
+ +

{title}

+ {onRestart ? ( + + ) : } +
+ + {(score != null || best != null) && ( +
+ {score != null && ( +
+

Score

+

{score}

+
+ )} + {best != null && ( +
+

Best

+

{best}

+
+ )} +
+ )} + +
{children}
+ {footer &&
{footer}
} +
+ ) +} diff --git a/src/games/MemoryMatch.tsx b/src/games/MemoryMatch.tsx new file mode 100644 index 0000000..e0659b3 --- /dev/null +++ b/src/games/MemoryMatch.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react' +import { useStore } from '../lib/store' +import GameShell from './GameShell' +import { Button } from '../components/ui' + +const EMOJIS = ['🍎', '🚀', '🌟', '🐱', '🌈', '⚽', '🎵', '🍕'] +type Card = { id: number; emoji: string; flipped: boolean; matched: boolean } + +function build(): Card[] { + return [...EMOJIS, ...EMOJIS] + .map((emoji, i) => ({ id: i, emoji, flipped: false, matched: false })) + .sort(() => Math.random() - 0.5) + .map((c, i) => ({ ...c, id: i })) +} + +export default function MemoryMatch() { + const { awardPoints, touchStreak } = useStore() + const [cards, setCards] = useState(build) + const [open, setOpen] = useState([]) + const [moves, setMoves] = useState(0) + const [busy, setBusy] = useState(false) + + const won = cards.length > 0 && cards.every((c) => c.matched) + + useEffect(() => { + if (won) { awardPoints(20); touchStreak() } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [won]) + + function flip(i: number) { + if (busy || cards[i].flipped || cards[i].matched) return + const nc = cards.map((c, idx) => (idx === i ? { ...c, flipped: true } : c)) + const no = [...open, i] + setCards(nc) + setOpen(no) + if (no.length === 2) { + setMoves((m) => m + 1) + setBusy(true) + const [a, b] = no + if (nc[a].emoji === nc[b].emoji) { + setTimeout(() => { + setCards((cs) => cs.map((c, idx) => (idx === a || idx === b ? { ...c, matched: true } : c))) + setOpen([]); setBusy(false) + }, 350) + } else { + setTimeout(() => { + setCards((cs) => cs.map((c, idx) => (idx === a || idx === b ? { ...c, flipped: false } : c))) + setOpen([]); setBusy(false) + }, 700) + } + } + } + + function restart() { setCards(build()); setOpen([]); setMoves(0); setBusy(false) } + + return ( + +

Flip cards to find matching pairs.

+
+ {cards.map((c, i) => ( + + ))} +
+ {won && ( +
+

🎉

+

Cleared in {moves} moves!

+

+20 points

+ +
+ )} +
+ ) +} diff --git a/src/games/QuickMath.tsx b/src/games/QuickMath.tsx new file mode 100644 index 0000000..6407ee2 --- /dev/null +++ b/src/games/QuickMath.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState, useCallback } from 'react' +import { useStore } from '../lib/store' +import GameShell from './GameShell' +import { Button } from '../components/ui' + +type Q = { text: string; answer: number; options: number[] } +const KEY = 'quickmath' + +function makeQ(level: number): Q { + const ops = ['+', '-', '×'] as const + const op = ops[Math.floor(Math.random() * (level > 3 ? 3 : 2))] + const max = 9 + level * 4 + let a = 1 + Math.floor(Math.random() * max) + let b = 1 + Math.floor(Math.random() * max) + let ans = 0 + if (op === '+') ans = a + b + else if (op === '-') { if (b > a) [a, b] = [b, a]; ans = a - b } + else { a = 1 + Math.floor(Math.random() * 12); b = 1 + Math.floor(Math.random() * 12); ans = a * b } + const opts = new Set([ans]) + while (opts.size < 4) opts.add(Math.max(0, ans + (Math.floor(Math.random() * 11) - 5))) + return { text: `${a} ${op} ${b}`, answer: ans, options: [...opts].sort(() => Math.random() - 0.5) } +} + +export default function QuickMath() { + const { bestScores, setBest, awardPoints, touchStreak } = useStore() + const [q, setQ] = useState(() => makeQ(1)) + const [score, setScore] = useState(0) + const [time, setTime] = useState(30) + const [over, setOver] = useState(false) + const [flash, setFlash] = useState<'ok' | 'no' | null>(null) + + useEffect(() => { + if (over) return + if (time <= 0) { setOver(true); return } + const t = setTimeout(() => setTime((v) => v - 1), 1000) + return () => clearTimeout(t) + }, [time, over]) + + useEffect(() => { + if (over) { + setBest(KEY, score) + if (score > 0) { awardPoints(Math.ceil(score / 2)); touchStreak() } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [over]) + + const answer = useCallback((n: number) => { + if (over) return + if (n === q.answer) { + setScore((s) => s + 1) + setTime((t) => Math.min(30, t + 1)) + setFlash('ok') + setQ(makeQ(1 + Math.floor((score + 1) / 5))) + } else { + setTime((t) => Math.max(0, t - 3)) + setFlash('no') + } + setTimeout(() => setFlash(null), 200) + }, [q, over, score]) + + function restart() { setScore(0); setTime(30); setOver(false); setQ(makeQ(1)) } + + return ( + + {over ? ( +
+

🧠

+

Time's up!

+

You solved {score} · earned +{Math.ceil(score / 2)} points

+ +
+ ) : ( +
+
+
+
+
+

{q.text}

+

{time}s left

+
+
+ {q.options.map((o, i) => ( + + ))} +
+
+ )} + + ) +} diff --git a/src/games/Snake.tsx b/src/games/Snake.tsx new file mode 100644 index 0000000..a42124a --- /dev/null +++ b/src/games/Snake.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react' +import { useStore } from '../lib/store' +import GameShell from './GameShell' +import { Button } from '../components/ui' + +const KEY = 'snake' +const N = 15 +type P = { x: number; y: number } + +export default function Snake() { + const { bestScores, setBest, awardPoints, touchStreak } = useStore() + const [snake, setSnake] = useState([{ x: 7, y: 7 }]) + const [food, setFood] = useState

({ x: 4, y: 4 }) + const [over, setOver] = useState(false) + const [running, setRunning] = useState(false) + const dir = useRef

({ x: 1, y: 0 }) + const nextDir = useRef

({ x: 1, y: 0 }) + const touch = useRef<{ x: number; y: number } | null>(null) + const score = snake.length - 1 + + useEffect(() => { + if (!running || over) return + const id = setInterval(() => { + setSnake((s) => { + dir.current = nextDir.current + const head = { x: s[0].x + dir.current.x, y: s[0].y + dir.current.y } + if (head.x < 0 || head.y < 0 || head.x >= N || head.y >= N || s.some((p) => p.x === head.x && p.y === head.y)) { + setOver(true); setRunning(false) + return s + } + const ns = [head, ...s] + if (head.x === food.x && head.y === food.y) { + let nf: P + do { nf = { x: Math.floor(Math.random() * N), y: Math.floor(Math.random() * N) } } + while (ns.some((p) => p.x === nf.x && p.y === nf.y)) + setFood(nf) + } else ns.pop() + return ns + }) + }, 160) + return () => clearInterval(id) + }, [running, over, food]) + + useEffect(() => { + if (over) { setBest(KEY, score); if (score > 0) { awardPoints(score * 2); touchStreak() } } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [over]) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const m: Record = { ArrowLeft: { x: -1, y: 0 }, ArrowRight: { x: 1, y: 0 }, ArrowUp: { x: 0, y: -1 }, ArrowDown: { x: 0, y: 1 } } + const d = m[e.key]; if (d) turn(d) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + function turn(d: P) { + if (d.x === -dir.current.x && d.y === -dir.current.y) return // no reverse + nextDir.current = d + if (!running && !over) setRunning(true) + } + function onStart(e: React.TouchEvent) { touch.current = { x: e.touches[0].clientX, y: e.touches[0].clientY } } + function onEnd(e: React.TouchEvent) { + if (!touch.current) return + const dx = e.changedTouches[0].clientX - touch.current.x + const dy = e.changedTouches[0].clientY - touch.current.y + if (Math.abs(dx) < 18 && Math.abs(dy) < 18) { if (!running && !over) setRunning(true); return } + if (Math.abs(dx) > Math.abs(dy)) turn({ x: dx > 0 ? 1 : -1, y: 0 }) + else turn({ x: 0, y: dy > 0 ? 1 : -1 }) + touch.current = null + } + function restart() { setSnake([{ x: 7, y: 7 }]); setFood({ x: 4, y: 4 }); dir.current = { x: 1, y: 0 }; nextDir.current = { x: 1, y: 0 }; setOver(false); setRunning(false) } + + return ( + +

+
+ {Array.from({ length: N * N }).map((_, i) => { + const x = i % N, y = Math.floor(i / N) + const isHead = snake[0].x === x && snake[0].y === y + const isBody = snake.some((p) => p.x === x && p.y === y) + const isFood = food.x === x && food.y === y + return
+ })} +
+ {!running && !over && ( +
+ +
+ )} + {over && ( +
+

Game over

+

Length {score} · +{score * 2} pts

+ +
+ )} +
+

Swipe to steer the snake. Eat the gold dots!

+ + ) +} diff --git a/src/index.css b/src/index.css index 7c4b6d7..47e9c8a 100644 --- a/src/index.css +++ b/src/index.css @@ -57,3 +57,35 @@ body { user-select: none; -webkit-user-select: none; } + +/* Rich-text rendering inside the note editor and note card previews */ +[contenteditable] ul, +.prose-preview ul { + list-style: disc; + padding-left: 1.25rem; +} +[contenteditable] ol, +.prose-preview ol { + list-style: decimal; + padding-left: 1.25rem; +} +[contenteditable] a, +.prose-preview a { + color: rgb(var(--brand)); + text-decoration: underline; +} +[contenteditable]:focus { + outline: none; +} + +/* Calendar month change — calm, smooth slide + fade */ +@keyframes page-next { + 0% { transform: translateX(22px); opacity: 0; } + 100% { transform: translateX(0); opacity: 1; } +} +@keyframes page-prev { + 0% { transform: translateX(-22px); opacity: 0; } + 100% { transform: translateX(0); opacity: 1; } +} +.page-next { animation: page-next 0.28s ease-out; } +.page-prev { animation: page-prev 0.28s ease-out; } diff --git a/src/lib/alarm.ts b/src/lib/alarm.ts new file mode 100644 index 0000000..5416664 --- /dev/null +++ b/src/lib/alarm.ts @@ -0,0 +1,75 @@ +// In-app alarm engine: when an "alarm" reminder is due while the app is open, +// it rings a looping tone + shows a full-screen alert (real-alarm feel). +// When the app is closed, the high-priority notification still fires with sound. +import { useStore } from './store' + +let ctx: AudioContext | null = null +let timer: any = null +let ringingId: string | null = null +const fired = new Set() +const listeners = new Set<(id: string | null) => void>() + +export function onAlarmRing(cb: (id: string | null) => void) { + listeners.add(cb) + return () => listeners.delete(cb) +} +function emit() { listeners.forEach((l) => l(ringingId)) } + +function beepLoop() { + try { + if (!ctx) ctx = new (window.AudioContext || (window as any).webkitAudioContext)() + const c = ctx + const tick = () => { + if (!ringingId) return + const o = c.createOscillator() + const g = c.createGain() + o.type = 'sine' + o.frequency.value = 880 + g.gain.setValueAtTime(0.0001, c.currentTime) + g.gain.exponentialRampToValueAtTime(0.5, c.currentTime + 0.02) + g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + 0.35) + o.connect(g); g.connect(c.destination) + o.start(); o.stop(c.currentTime + 0.4) + } + tick() + timer = setInterval(tick, 700) + } catch { + /* audio not available */ + } + try { navigator.vibrate?.([500, 300, 500, 300, 500]) } catch {} +} + +export function ringAlarm(id: string) { + if (ringingId) return + ringingId = id + emit() + beepLoop() +} + +export function stopAlarm() { + ringingId = null + if (timer) { clearInterval(timer); timer = null } + emit() +} + +// Returns the currently-ringing reminder's title (or null). +export function ringingTitle(): string | null { + if (!ringingId) return null + return useStore.getState().reminders.find((r) => r.id === ringingId)?.title || 'Alarm' +} + +// Starts a watcher (call once on app load) that triggers alarms when due. +export function startAlarmWatcher() { + const check = () => { + if (ringingId) return + const now = Date.now() + const due = useStore + .getState() + .reminders.find((r: any) => r.alarm && !r.done && !fired.has(r.id) && r.at <= now && now - r.at < 120000) + if (due) { fired.add(due.id); ringAlarm(due.id) } + } + setInterval(check, 5000) + check() +} + +export function clearFired(id: string) { fired.delete(id) } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index dd79c7f..56dd449 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,9 +6,10 @@ import { signOut as fbSignOut, onAuthStateChanged, updateProfile, + sendPasswordResetEmail, type User, } from 'firebase/auth' -import { doc, setDoc, serverTimestamp } from 'firebase/firestore' +import { doc, setDoc, serverTimestamp, collection, query, where, getDocs, limit } from 'firebase/firestore' import { getFb, firebaseReady } from './firebase' import { useStore } from './store' @@ -47,12 +48,35 @@ export function useAuth(): AuthState { return { user, loading, ready: firebaseReady } } +// Username rules (Instagram-style): 3–20 chars, letters/numbers/._ only. +export function validUsername(u: string) { + return /^[a-z0-9._]{3,20}$/.test(u.toLowerCase()) +} + +export async function usernameAvailable(username: string): Promise { + const fb = getFb() + if (!fb) return false + const q = query(collection(fb.db, 'users'), where('usernameLower', '==', username.toLowerCase().trim()), limit(1)) + const snap = await getDocs(q) + return snap.empty +} + +async function emailForUsername(username: string): Promise { + const fb = getFb() + if (!fb) return null + const q = query(collection(fb.db, 'users'), where('usernameLower', '==', username.toLowerCase().trim()), limit(1)) + const snap = await getDocs(q) + if (snap.empty) return null + return (snap.docs[0].data() as any).email || null +} + export async function signUp(email: string, password: string, username: string) { const fb = getFb() if (!fb) throw new Error('offline') + if (!validUsername(username)) throw new Error('Username must be 3–20 chars (letters, numbers, . or _)') + if (!(await usernameAvailable(username))) throw new Error('That username is already taken') const cred = await createUserWithEmailAndPassword(fb.auth, email, password) await updateProfile(cred.user, { displayName: username }) - // Public profile doc so others can find you to chat. await setDoc(doc(fb.db, 'users', cred.user.uid), { uid: cred.user.uid, username, @@ -63,13 +87,37 @@ export async function signUp(email: string, password: string, username: string) return cred.user } -export async function signIn(email: string, password: string) { +// Accepts an email OR a username as the identifier. +export async function signIn(identifier: string, password: string) { const fb = getFb() if (!fb) throw new Error('offline') + let email = identifier.trim() + if (!email.includes('@')) { + const found = await emailForUsername(email) + if (!found) throw new Error('No account with that username') + email = found + } const cred = await signInWithEmailAndPassword(fb.auth, email, password) return cred.user } +// Sync a changed username to the public profile (so chat search finds you). +// Returns true on success, false if taken/invalid/offline. +export async function syncUsername(uid: string, username: string): Promise { + const fb = getFb() + if (!fb || !validUsername(username)) return false + const cur = await getDocs(query(collection(fb.db, 'users'), where('usernameLower', '==', username.toLowerCase()), limit(1))) + if (!cur.empty && (cur.docs[0].data() as any).uid !== uid) return false // taken by someone else + await setDoc(doc(fb.db, 'users', uid), { username, usernameLower: username.toLowerCase() }, { merge: true }) + return true +} + +export async function resetPassword(email: string) { + const fb = getFb() + if (!fb) throw new Error('offline') + await sendPasswordResetEmail(fb.auth, email.trim()) +} + export async function signOut() { const fb = getFb() if (fb) await fbSignOut(fb.auth) diff --git a/src/lib/chat.ts b/src/lib/chat.ts index 72d1575..d2ac0e1 100644 --- a/src/lib/chat.ts +++ b/src/lib/chat.ts @@ -8,14 +8,19 @@ import { addDoc, doc, setDoc, + getDoc, getDocs, + updateDoc, + deleteDoc, serverTimestamp, limit, } from 'firebase/firestore' import { getFb } from './firebase' import { uploadToCloudinary } from './cloudinary' -export type MsgKind = 'text' | 'image' | 'gif' | 'voice' | 'file' +export type MsgKind = 'text' | 'image' | 'gif' | 'voice' | 'file' | 'video' + +export interface ReplyRef { id: string; snippet: string; from: string } export interface Message { id: string @@ -23,6 +28,8 @@ export interface Message { kind: MsgKind text?: string mediaUrl?: string + reactions?: Record + replyTo?: ReplyRef createdAt: number } @@ -69,12 +76,14 @@ export async function ensureConversation(me: ChatUser, other: ChatUser) { export function listenConversations(uid: string, cb: (rows: any[]) => void) { const fb = getFb() if (!fb) return () => {} - const q = query( - collection(fb.db, 'conversations'), - where('members', 'array-contains', uid), - orderBy('updatedAt', 'desc'), - ) - return onSnapshot(q, (snap) => cb(snap.docs.map((d) => ({ id: d.id, ...d.data() })))) + // No orderBy here — combining array-contains with orderBy needs a composite + // index (which silently returns nothing if missing). Sort on the client. + const q = query(collection(fb.db, 'conversations'), where('members', 'array-contains', uid)) + return onSnapshot(q, (snap) => { + const rows = snap.docs.map((d) => ({ id: d.id, ...d.data() })) + rows.sort((a: any, b: any) => (b.updatedAt?.toMillis?.() ?? 0) - (a.updatedAt?.toMillis?.() ?? 0)) + cb(rows) + }) } export function listenMessages(cid: string, cb: (msgs: Message[]) => void) { @@ -95,6 +104,8 @@ export function listenMessages(cid: string, cb: (msgs: Message[]) => void) { kind: data.kind, text: data.text, mediaUrl: data.mediaUrl, + reactions: data.reactions || {}, + replyTo: data.replyTo || undefined, createdAt: data.createdAt?.toMillis?.() ?? Date.now(), } }), @@ -105,15 +116,15 @@ export function listenMessages(cid: string, cb: (msgs: Message[]) => void) { export async function sendMessage( cid: string, from: string, - payload: { kind: MsgKind; text?: string; mediaUrl?: string }, + payload: { kind: MsgKind; text?: string; mediaUrl?: string; replyTo?: ReplyRef }, ) { const fb = getFb() if (!fb) return - await addDoc(collection(fb.db, 'conversations', cid, 'messages'), { - from, - ...payload, - createdAt: serverTimestamp(), - }) + const clean: any = { from, kind: payload.kind, createdAt: serverTimestamp() } + if (payload.text != null) clean.text = payload.text + if (payload.mediaUrl != null) clean.mediaUrl = payload.mediaUrl + if (payload.replyTo) clean.replyTo = payload.replyTo + await addDoc(collection(fb.db, 'conversations', cid, 'messages'), clean) await setDoc( doc(fb.db, 'conversations', cid), { updatedAt: serverTimestamp(), lastText: payload.text || `[${payload.kind}]` }, @@ -121,6 +132,61 @@ export async function sendMessage( ) } +// ---- presence, typing & read receipts ---- +export async function setTyping(cid: string, uid: string, isTyping: boolean) { + const fb = getFb() + if (!fb) return + await setDoc(doc(fb.db, 'conversations', cid), { typing: { [uid]: isTyping ? Date.now() : 0 } }, { merge: true }) +} + +export async function markRead(cid: string, uid: string) { + const fb = getFb() + if (!fb) return + await setDoc(doc(fb.db, 'conversations', cid), { lastRead: { [uid]: Date.now() } }, { merge: true }) +} + +export function listenConversation(cid: string, cb: (data: any) => void) { + const fb = getFb() + if (!fb) return () => {} + return onSnapshot(doc(fb.db, 'conversations', cid), (snap) => cb(snap.data() || {})) +} + +export async function heartbeat(uid: string) { + const fb = getFb() + if (!fb) return + await setDoc(doc(fb.db, 'users', uid), { lastSeen: Date.now() }, { merge: true }) +} + +export function listenPresence(uid: string, cb: (lastSeen: number) => void) { + const fb = getFb() + if (!fb) return () => {} + return onSnapshot(doc(fb.db, 'users', uid), (snap) => cb((snap.data() as any)?.lastSeen || 0)) +} + +export async function getConversation(cid: string): Promise { + const fb = getFb() + if (!fb) return null + const snap = await getDoc(doc(fb.db, 'conversations', cid)) + return snap.exists() ? snap.data() : null +} + +export async function toggleReaction(cid: string, msgId: string, uid: string, emoji: string) { + const fb = getFb() + if (!fb) return + const ref = doc(fb.db, 'conversations', cid, 'messages', msgId) + const snap = await getDoc(ref) + const reactions = { ...(snap.data()?.reactions || {}) } + if (reactions[uid] === emoji) delete reactions[uid] + else reactions[uid] = emoji + await updateDoc(ref, { reactions }) +} + +export async function deleteMessage(cid: string, msgId: string) { + const fb = getFb() + if (!fb) return + await deleteDoc(doc(fb.db, 'conversations', cid, 'messages', msgId)) +} + export async function uploadMedia(_cid: string, file: Blob, ext: string): Promise { // Media goes to Cloudinary (free); 'auto' lets it detect image/video/audio. const isImg = /^(png|jpe?g|gif|webp|heic|bmp)$/i.test(ext) diff --git a/src/lib/engagement.ts b/src/lib/engagement.ts new file mode 100644 index 0000000..dc00293 --- /dev/null +++ b/src/lib/engagement.ts @@ -0,0 +1,53 @@ +// Zomato/Duolingo-style motivational nudges via daily-repeating local +// notifications (no server needed). Fires several times through the day so the +// app keeps pulling you back. Fire even when the app is closed. +import { scheduleReminder, cancelReminder, ensureNotificationPermission } from './notifications' +import type { Settings } from './store' + +const BASE_ID = 910100 +// Daytime hours to nudge at (every hour, 8am–10pm). +const HOURS = [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22] + +const LINES = [ + '🔥 Keep your streak alive — finish one task!', + '📝 Got a thought? Jot it in Vintly notes.', + '🎮 Quick break? A round of Car Racing awaits.', + '⚡ 2 minutes, 1 task. You’ve got this!', + '🏆 You’re close to a new points record!', + '🚶 How are your steps looking today?', + '✨ Plan your evening — what’s the one win?', + '🌙 Wind down: tick off today’s last task.', + '💪 Consistency beats intensity. Open Vintly!', + '📅 Anything on the calendar you’re forgetting?', +] +const MORNING = ['☀️ Good morning! Plan your day in Vintly.', '📝 3 wins for today — add them now.', '✨ Fresh day, fresh streak. Let’s go!'] +const pick = (a: string[]) => a[Math.floor(Math.random() * a.length)] + +function nextAt(hour: number, min = 0): Date { + const d = new Date() + d.setHours(hour, min, 0, 0) + if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1) + return d +} + +export async function syncEngagementNudges(settings: Settings) { + await ensureNotificationPermission() + + // Morning planner (own toggle + time) + if (settings.morningNudge) { + const [h, m] = settings.morningNudgeTime.split(':').map(Number) + await scheduleReminder({ id: BASE_ID - 1, title: 'Vintly', body: pick(MORNING), at: nextAt(h || 9, m || 0), repeat: 'daily' }) + } else { + await cancelReminder(BASE_ID - 1) + } + + // Frequent daytime nudges (~every 2h) — controlled by the streak-reminder toggle. + for (let i = 0; i < HOURS.length; i++) { + const id = BASE_ID + i + if (settings.streakReminder) { + await scheduleReminder({ id, title: 'Vintly', body: pick(LINES), at: nextAt(HOURS[i]), repeat: 'daily' }) + } else { + await cancelReminder(id) + } + } +} diff --git a/src/lib/health.ts b/src/lib/health.ts new file mode 100644 index 0000000..92db91a --- /dev/null +++ b/src/lib/health.ts @@ -0,0 +1,63 @@ +// Real step counting via Android Health Connect (free, no card needed). +// The OS / Health Connect counts steps 24/7 using the phone's hardware +// pedometer; we just read the aggregated total. Degrades gracefully when +// Health Connect isn't available (older phones, web) — the caller can then +// fall back to the in-app motion-sensor counter. +import { Capacitor } from '@capacitor/core' + +let plugin: any = null + +async function load(): Promise { + if (!Capacitor.isNativePlatform()) return null + if (!plugin) { + try { + const mod: any = await import('capacitor-health') + plugin = mod.Health || mod.default || null + } catch { + return null + } + } + return plugin +} + +export async function healthAvailable(): Promise { + const p = await load() + if (!p) return false + try { + const r = await p.isHealthAvailable() + return !!r?.available + } catch { + return false + } +} + +export async function requestStepsPermission(): Promise { + const p = await load() + if (!p) return false + try { + await p.requestHealthPermissions({ permissions: ['READ_STEPS'] }) + return true + } catch { + return false + } +} + +export async function getTodaySteps(): Promise { + const p = await load() + if (!p) return null + const start = new Date() + start.setHours(0, 0, 0, 0) + try { + const res = await p.queryAggregated({ + startDate: start.toISOString(), + endDate: new Date().toISOString(), + dataType: 'steps', + bucket: 'day', + }) + const data = res?.aggregatedData || [] + const total = data.reduce((a: number, d: any) => a + (Number(d?.value) || 0), 0) + return Math.round(total) + } catch { + return null + } +} diff --git a/src/lib/holidays.ts b/src/lib/holidays.ts new file mode 100644 index 0000000..50459f1 --- /dev/null +++ b/src/lib/holidays.ts @@ -0,0 +1,68 @@ +// Indian holidays & festivals shown on the calendar (like Google Calendar). +// National/fixed dates are exact; lunar festival dates are best-known estimates +// and may shift by a day regionally. + +export type HolidayType = 'national' | 'festival' +export interface Holiday { date: string; name: string; type: HolidayType } + +export const HOLIDAYS: Holiday[] = [ + // ---- 2026 ---- + { date: '2026-01-01', name: 'New Year’s Day', type: 'national' }, + { date: '2026-01-13', name: 'Lohri', type: 'festival' }, + { date: '2026-01-14', name: 'Makar Sankranti / Pongal', type: 'festival' }, + { date: '2026-01-23', name: 'Basant Panchami', type: 'festival' }, + { date: '2026-01-26', name: 'Republic Day', type: 'national' }, + { date: '2026-02-15', name: 'Maha Shivaratri', type: 'festival' }, + { date: '2026-03-03', name: 'Holika Dahan', type: 'festival' }, + { date: '2026-03-04', name: 'Holi', type: 'festival' }, + { date: '2026-03-21', name: 'Eid-ul-Fitr', type: 'festival' }, + { date: '2026-03-26', name: 'Ram Navami', type: 'festival' }, + { date: '2026-03-31', name: 'Mahavir Jayanti', type: 'festival' }, + { date: '2026-04-02', name: 'Hanuman Jayanti', type: 'festival' }, + { date: '2026-04-03', name: 'Good Friday', type: 'festival' }, + { date: '2026-04-14', name: 'Ambedkar Jayanti / Baisakhi', type: 'national' }, + { date: '2026-05-01', name: 'May Day', type: 'national' }, + { date: '2026-05-01', name: 'Buddha Purnima', type: 'festival' }, + { date: '2026-05-27', name: 'Bakrid (Eid-ul-Adha)', type: 'festival' }, + { date: '2026-06-26', name: 'Muharram', type: 'festival' }, + { date: '2026-06-29', name: 'Rath Yatra', type: 'festival' }, + { date: '2026-07-29', name: 'Guru Purnima', type: 'festival' }, + { date: '2026-08-15', name: 'Independence Day', type: 'national' }, + { date: '2026-08-26', name: 'Raksha Bandhan', type: 'festival' }, + { date: '2026-08-28', name: 'Onam', type: 'festival' }, + { date: '2026-09-04', name: 'Janmashtami', type: 'festival' }, + { date: '2026-09-14', name: 'Ganesh Chaturthi', type: 'festival' }, + { date: '2026-10-02', name: 'Gandhi Jayanti', type: 'national' }, + { date: '2026-10-11', name: 'Navratri begins', type: 'festival' }, + { date: '2026-10-17', name: 'Durga Puja — Shashthi', type: 'festival' }, + { date: '2026-10-18', name: 'Durga Puja — Saptami', type: 'festival' }, + { date: '2026-10-19', name: 'Durga Puja — Ashtami', type: 'festival' }, + { date: '2026-10-20', name: 'Durga Puja — Navami', type: 'festival' }, + { date: '2026-10-20', name: 'Dussehra (Vijayadashami)', type: 'festival' }, + { date: '2026-10-27', name: 'Karva Chauth', type: 'festival' }, + { date: '2026-11-06', name: 'Dhanteras', type: 'festival' }, + { date: '2026-11-08', name: 'Diwali (Deepavali)', type: 'festival' }, + { date: '2026-11-09', name: 'Govardhan Puja', type: 'festival' }, + { date: '2026-11-10', name: 'Bhai Dooj', type: 'festival' }, + { date: '2026-11-15', name: 'Chhath Puja', type: 'festival' }, + { date: '2026-11-24', name: 'Guru Nanak Jayanti', type: 'festival' }, + { date: '2026-12-25', name: 'Christmas', type: 'national' }, + // ---- early 2027 (so the calendar isn't empty next year) ---- + { date: '2027-01-01', name: 'New Year’s Day', type: 'national' }, + { date: '2027-01-26', name: 'Republic Day', type: 'national' }, + { date: '2027-03-22', name: 'Holi', type: 'festival' }, +] + +const byDate = new Map() +for (const h of HOLIDAYS) { + const arr = byDate.get(h.date) || [] + arr.push(h) + byDate.set(h.date, arr) +} + +const key = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + +export function holidaysOn(d: Date): Holiday[] { + return byDate.get(key(d)) || [] +} diff --git a/src/lib/pedometer.ts b/src/lib/pedometer.ts new file mode 100644 index 0000000..ffff168 --- /dev/null +++ b/src/lib/pedometer.ts @@ -0,0 +1,77 @@ +// Global pedometer service — runs independently of any screen so step counting +// keeps going while you move around the app (notes, chat, etc.). Uses the +// device motion sensor with a gravity-adaptive peak detector. Health Connect +// (lib/health.ts) is preferred when available; this is the in-app fallback. +import { Capacitor } from '@capacitor/core' +import { useStore } from './store' + +let running = false +let remove: null | (() => void) = null +let lastPeak = 0 +let gravity = 9.8 +let armed = true +const listeners = new Set<(on: boolean) => void>() + +function onAccel(x: number, y: number, z: number) { + const mag = Math.sqrt(x * x + y * y + z * z) + gravity = gravity * 0.9 + mag * 0.1 + const linear = mag - gravity + const now = Date.now() + if (armed && linear > 1.1 && now - lastPeak > 270) { + armed = false + lastPeak = now + useStore.getState().setSteps(useStore.getState().steps.count + 1) + } + if (linear < 0.35) armed = true +} + +export function isPedometerRunning() { + return running +} +export function onPedometerChange(cb: (on: boolean) => void) { + listeners.add(cb) + return () => listeners.delete(cb) +} +function emit() { + listeners.forEach((l) => l(running)) +} + +export async function startPedometer(): Promise { + if (running) return true + try { + if (Capacitor.isNativePlatform()) { + const { Motion } = await import('@capacitor/motion') + const handle = await Motion.addListener('accel', (e: any) => { + const a = e.accelerationIncludingGravity || e.acceleration + if (a) onAccel(a.x, a.y, a.z) + }) + remove = () => handle.remove() + } else if (typeof DeviceMotionEvent !== 'undefined') { + const anyDME = DeviceMotionEvent as any + if (typeof anyDME.requestPermission === 'function') { + const res = await anyDME.requestPermission() + if (res !== 'granted') return false + } + const listener = (e: DeviceMotionEvent) => { + const a = e.accelerationIncludingGravity + if (a && a.x != null) onAccel(a.x, a.y!, a.z!) + } + window.addEventListener('devicemotion', listener) + remove = () => window.removeEventListener('devicemotion', listener) + } else { + return false + } + running = true + emit() + return true + } catch { + return false + } +} + +export function stopPedometer() { + remove?.() + remove = null + running = false + emit() +} diff --git a/src/lib/store.ts b/src/lib/store.ts index 9c2aac9..29b8245 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -27,6 +27,10 @@ export interface Note { isChecklist: boolean checklist: ChecklistItem[] labels: string[] + font: string // '', 'serif', 'mono', 'rounded' + bg: string // background pattern key for the note + trashed: boolean + trashedAt?: number updatedAt: number } @@ -47,6 +51,7 @@ export interface Reminder { notifId?: number repeat: 'none' | 'daily' | 'weekly' done: boolean + alarm?: boolean } export interface Profile { @@ -60,9 +65,14 @@ export interface Profile { export interface Settings { theme: ThemeName accent: string // rgb triple + customBg: string // rgb triple for custom theme background chatWallpaper: string // css value or '' hapticsEnabled: boolean stepGoal: number + streakReminder: boolean + streakReminderTime: string // 'HH:MM' + morningNudge: boolean + morningNudgeTime: string } interface Engagement { @@ -82,6 +92,8 @@ interface VintlyState { engagement: Engagement settings: Settings steps: { day: string; count: number } + bestScores: Record + pinnedChats: string[] // profile setProfile: (p: Partial) => void @@ -96,6 +108,8 @@ interface VintlyState { addNote: (n: Partial) => string updateNote: (id: string, patch: Partial) => void deleteNote: (id: string) => void + trashNote: (id: string) => void + restoreNote: (id: string) => void // events addEvent: (e: Partial) => void @@ -114,6 +128,12 @@ interface VintlyState { // fitness setSteps: (count: number) => void + + // games + setBest: (game: string, score: number) => void + + // chat + togglePinChat: (cid: string) => void } const todayKey = () => new Date().toISOString().slice(0, 10) @@ -139,11 +159,18 @@ export const useStore = create()( settings: { theme: 'midnight', accent: '124 92 255', + customBg: '18 16 28', chatWallpaper: '', hapticsEnabled: true, stepGoal: 8000, + streakReminder: true, + streakReminderTime: '20:00', + morningNudge: true, + morningNudgeTime: '09:00', }, steps: { day: todayKey(), count: 0 }, + bestScores: {}, + pinnedChats: [], setProfile: (p) => set((s) => ({ profile: { ...s.profile, ...p } })), @@ -199,6 +226,9 @@ export const useStore = create()( isChecklist: n.isChecklist ?? false, checklist: n.checklist ?? [], labels: n.labels ?? [], + font: n.font ?? '', + bg: n.bg ?? '', + trashed: false, updatedAt: Date.now(), }, ...s.notes, @@ -211,6 +241,10 @@ export const useStore = create()( notes: s.notes.map((n) => (n.id === id ? { ...n, ...patch, updatedAt: Date.now() } : n)), })), deleteNote: (id) => set((s) => ({ notes: s.notes.filter((n) => n.id !== id) })), + trashNote: (id) => + set((s) => ({ notes: s.notes.map((n) => (n.id === id ? { ...n, trashed: true, trashedAt: Date.now(), pinned: false } : n)) })), + restoreNote: (id) => + set((s) => ({ notes: s.notes.map((n) => (n.id === id ? { ...n, trashed: false, trashedAt: undefined } : n)) })), addEvent: (e) => set((s) => ({ @@ -258,13 +292,25 @@ export const useStore = create()( setSettings: (s) => { set((state) => ({ settings: { ...state.settings, ...s } })) const ns = get().settings - applyTheme(ns.theme, ns.accent) + applyTheme(ns.theme, ns.accent, ns.customBg) }, setSteps: (count) => { const day = todayKey() set((s) => ({ steps: { day, count: s.steps.day === day ? count : count } })) }, + + setBest: (game, score) => + set((s) => ({ + bestScores: { ...s.bestScores, [game]: Math.max(s.bestScores?.[game] || 0, score) }, + })), + + togglePinChat: (cid) => + set((s) => ({ + pinnedChats: (s.pinnedChats || []).includes(cid) + ? s.pinnedChats.filter((c) => c !== cid) + : [...(s.pinnedChats || []), cid], + })), }), { name: 'vintly-store-v1', @@ -276,9 +322,20 @@ export const useStore = create()( isChecklist: false, checklist: [], labels: [], + font: '', + bg: '', + trashed: false, ...n, })) - applyTheme(state.settings.theme, state.settings.accent) + state.settings = { + streakReminder: true, + streakReminderTime: '20:00', + morningNudge: true, + morningNudgeTime: '09:00', + customBg: '18 16 28', + ...state.settings, + } + applyTheme(state.settings.theme, state.settings.accent, state.settings.customBg) }, }, ), diff --git a/src/lib/theme.ts b/src/lib/theme.ts index c0f3467..c4ed879 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,41 +1,20 @@ // Runtime theming so users can recolor the whole app (premium / customizable feel). -export type ThemeName = 'midnight' | 'light' | 'mocha' | 'ocean' +export type ThemeName = 'midnight' | 'light' | 'mocha' | 'ocean' | 'forest' | 'rose' | 'slate' | 'grape' | 'custom' type Palette = Record -const THEMES: Record = { - midnight: { - '--surface': '11 15 26', - '--card': '20 25 41', - '--ink': '232 236 245', - '--muted': '148 158 178', - '--line': '38 44 64', - }, - light: { - '--surface': '245 247 251', - '--card': '255 255 255', - '--ink': '17 24 39', - '--muted': '107 114 128', - '--line': '226 232 240', - }, - mocha: { - '--surface': '24 18 16', - '--card': '38 28 25', - '--ink': '243 234 228', - '--muted': '176 158 148', - '--line': '64 48 42', - }, - ocean: { - '--surface': '8 18 28', - '--card': '14 28 42', - '--ink': '224 240 248', - '--muted': '138 162 182', - '--line': '30 50 68', - }, +const THEMES: Record = { + midnight: { '--surface': '11 15 26', '--card': '20 25 41', '--ink': '232 236 245', '--muted': '148 158 178', '--line': '38 44 64' }, + light: { '--surface': '245 247 251', '--card': '255 255 255', '--ink': '17 24 39', '--muted': '107 114 128', '--line': '226 232 240' }, + mocha: { '--surface': '24 18 16', '--card': '38 28 25', '--ink': '243 234 228', '--muted': '176 158 148', '--line': '64 48 42' }, + ocean: { '--surface': '8 18 28', '--card': '14 28 42', '--ink': '224 240 248', '--muted': '138 162 182', '--line': '30 50 68' }, + forest: { '--surface': '10 20 16', '--card': '16 32 25', '--ink': '226 240 230', '--muted': '142 170 152', '--line': '34 54 44' }, + rose: { '--surface': '26 14 18', '--card': '40 22 28', '--ink': '245 230 235', '--muted': '188 152 164', '--line': '64 38 46' }, + slate: { '--surface': '16 18 22', '--card': '26 29 35', '--ink': '232 234 240', '--muted': '150 156 168', '--line': '44 48 56' }, + grape: { '--surface': '20 14 30', '--card': '32 22 48', '--ink': '236 230 248', '--muted': '170 156 192', '--line': '52 40 72' }, } -// Curated accent presets (rgb triples), plus free custom color support. export const ACCENTS: { name: string; value: string }[] = [ { name: 'Violet', value: '124 92 255' }, { name: 'Emerald', value: '16 185 129' }, @@ -43,6 +22,10 @@ export const ACCENTS: { name: string; value: string }[] = [ { name: 'Rose', value: '244 63 94' }, { name: 'Amber', value: '245 158 11' }, { name: 'Indigo', value: '99 102 241' }, + { name: 'Pink', value: '236 72 153' }, + { name: 'Teal', value: '20 184 166' }, + { name: 'Orange', value: '249 115 22' }, + { name: 'Lime', value: '132 204 22' }, ] export function hexToRgbTriple(hex: string): string | null { @@ -52,18 +35,33 @@ export function hexToRgbTriple(hex: string): string | null { return `${(int >> 16) & 255} ${(int >> 8) & 255} ${int & 255}` } -export function applyTheme(theme: ThemeName, accent: string) { +// Build a full palette from a single background color (custom theme). +function deriveCustom(bg: string): Palette { + const [r, g, b] = bg.split(' ').map(Number) + const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + const dark = lum < 0.5 + const mix = (a: number, t: [number, number, number]) => + `${Math.round(r * (1 - a) + t[0] * a)} ${Math.round(g * (1 - a) + t[1] * a)} ${Math.round(b * (1 - a) + t[2] * a)}` + const toward: [number, number, number] = dark ? [255, 255, 255] : [0, 0, 0] + return { + '--surface': bg, + '--card': mix(0.08, toward), + '--ink': dark ? '240 242 248' : '17 24 39', + '--muted': dark ? '160 168 184' : '90 96 110', + '--line': mix(0.16, toward), + } +} + +export function applyTheme(theme: ThemeName, accent: string, customBg?: string) { const root = document.documentElement - const palette = THEMES[theme] ?? THEMES.midnight + const palette = theme === 'custom' && customBg ? deriveCustom(customBg) : THEMES[theme] ?? THEMES.midnight Object.entries(palette).forEach(([k, v]) => root.style.setProperty(k, v)) root.style.setProperty('--brand', accent) - // A soft, dimmed version of the accent for backgrounds. const [r, g, b] = accent.split(' ').map(Number) - root.style.setProperty( - '--brand-soft', - `${Math.round(r * 0.4)} ${Math.round(g * 0.4)} ${Math.round(b * 0.45)}`, - ) - root.classList.toggle('dark', theme !== 'light') + root.style.setProperty('--brand-soft', `${Math.round(r * 0.4)} ${Math.round(g * 0.4)} ${Math.round(b * 0.45)}`) + // light mode = light theme, or custom with a bright background + const isLight = theme === 'light' || (theme === 'custom' && customBg && (() => { const [cr, cg, cb] = customBg.split(' ').map(Number); return (0.299 * cr + 0.587 * cg + 0.114 * cb) / 255 >= 0.5 })()) + root.classList.toggle('dark', !isLight) const meta = document.querySelector('meta[name="theme-color"]') - if (meta) meta.setAttribute('content', theme === 'light' ? '#f5f7fb' : '#0b0f1a') + if (meta) meta.setAttribute('content', isLight ? '#f5f7fb' : '#0b0f1a') } diff --git a/src/lib/weather.ts b/src/lib/weather.ts new file mode 100644 index 0000000..f6c76e4 --- /dev/null +++ b/src/lib/weather.ts @@ -0,0 +1,100 @@ +// Live weather via Open-Meteo (free, no API key). Location via IP (no native +// permission needed) with manual area search. Accurate current + hourly + daily. + +export interface Place { name: string; country: string; admin1?: string; lat: number; lon: number } +export interface CurrentWx { + temp: number; feels: number; humidity: number; precip: number + wind: number; code: number; isDay: boolean +} +export interface DailyWx { + date: string; code: number; tMax: number; tMin: number; rain: number; wind: number; sunrise: string; sunset: string +} +export interface HourlyWx { time: string; temp: number; rain: number; code: number } +export interface WeatherData { current: CurrentWx; daily: DailyWx[]; hourly: HourlyWx[] } + +// WMO weather codes → label + emoji +const CODES: Record = { + 0: ['Clear sky', '☀️'], 1: ['Mainly clear', '🌤️'], 2: ['Partly cloudy', '⛅'], 3: ['Overcast', '☁️'], + 45: ['Fog', '🌫️'], 48: ['Rime fog', '🌫️'], + 51: ['Light drizzle', '🌦️'], 53: ['Drizzle', '🌦️'], 55: ['Heavy drizzle', '🌧️'], + 61: ['Light rain', '🌦️'], 63: ['Rain', '🌧️'], 65: ['Heavy rain', '🌧️'], + 66: ['Freezing rain', '🌧️'], 67: ['Freezing rain', '🌧️'], + 71: ['Light snow', '🌨️'], 73: ['Snow', '❄️'], 75: ['Heavy snow', '❄️'], 77: ['Snow grains', '🌨️'], + 80: ['Rain showers', '🌦️'], 81: ['Rain showers', '🌧️'], 82: ['Violent showers', '⛈️'], + 85: ['Snow showers', '🌨️'], 86: ['Snow showers', '❄️'], + 95: ['Thunderstorm', '⛈️'], 96: ['Thunderstorm + hail', '⛈️'], 99: ['Thunderstorm + hail', '⛈️'], +} +export function wxText(code: number) { return CODES[code]?.[0] || 'Unknown' } +export function wxEmoji(code: number) { return CODES[code]?.[1] || '🌡️' } + +export async function searchPlaces(q: string): Promise { + if (!q.trim()) return [] + const r = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=6&language=en&format=json`) + if (!r.ok) return [] + const j = await r.json() + return (j.results || []).map((p: any) => ({ name: p.name, country: p.country, admin1: p.admin1, lat: p.latitude, lon: p.longitude })) +} + +async function reverseName(lat: number, lon: number): Promise<{ name: string; country: string }> { + try { + const r = await fetch(`https://geocoding-api.open-meteo.com/v1/reverse?latitude=${lat}&longitude=${lon}&count=1&language=en`) + const j = await r.json() + const p = j.results?.[0] + return { name: p?.name || 'My location', country: p?.country || '' } + } catch { return { name: 'My location', country: '' } } +} + +async function ipProviders(): Promise { + // Try a couple of free IP-geolocation services for resilience. + try { + const r = await fetch('https://ipapi.co/json/') + if (r.ok) { const j = await r.json(); if (j.latitude != null) return { name: j.city || 'My location', country: j.country_name || '', admin1: j.region, lat: j.latitude, lon: j.longitude } } + } catch {} + try { + const r = await fetch('https://ipwho.is/') + if (r.ok) { const j = await r.json(); if (j.success && j.latitude != null) return { name: j.city || 'My location', country: j.country || '', admin1: j.region, lat: j.latitude, lon: j.longitude } } + } catch {} + return null +} + +// Best-effort current location: precise GPS (if the webview allows) → IP fallback. +export async function locateByIP(): Promise { + const gps = await new Promise((resolve) => { + if (!('geolocation' in navigator)) return resolve(null) + const t = setTimeout(() => resolve(null), 6000) + navigator.geolocation.getCurrentPosition( + async (pos) => { clearTimeout(t); const n = await reverseName(pos.coords.latitude, pos.coords.longitude); resolve({ ...n, lat: pos.coords.latitude, lon: pos.coords.longitude }) }, + () => { clearTimeout(t); resolve(null) }, + { enableHighAccuracy: false, timeout: 6000, maximumAge: 600000 }, + ) + }) + return gps || (await ipProviders()) +} + +export async function getWeather(lat: number, lon: number, days = 16): Promise { + const url = + `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` + + `¤t=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,is_day` + + `&hourly=temperature_2m,precipitation_probability,weather_code` + + `&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max,sunrise,sunset` + + `&timezone=auto&forecast_days=${days}` + const r = await fetch(url) + const j = await r.json() + const c = j.current + const current: CurrentWx = { + temp: Math.round(c.temperature_2m), feels: Math.round(c.apparent_temperature), humidity: c.relative_humidity_2m, + precip: c.precipitation, wind: Math.round(c.wind_speed_10m), code: c.weather_code, isDay: !!c.is_day, + } + const daily: DailyWx[] = j.daily.time.map((d: string, i: number) => ({ + date: d, code: j.daily.weather_code[i], tMax: Math.round(j.daily.temperature_2m_max[i]), + tMin: Math.round(j.daily.temperature_2m_min[i]), rain: j.daily.precipitation_probability_max[i] ?? 0, + wind: Math.round(j.daily.wind_speed_10m_max[i]), sunrise: j.daily.sunrise[i], sunset: j.daily.sunset[i], + })) + // next 24 hourly entries from now + const now = Date.now() + const hourly: HourlyWx[] = j.hourly.time + .map((t: string, i: number) => ({ time: t, temp: Math.round(j.hourly.temperature_2m[i]), rain: j.hourly.precipitation_probability[i] ?? 0, code: j.hourly.weather_code[i] })) + .filter((h: HourlyWx) => new Date(h.time).getTime() >= now - 3600000) + .slice(0, 24) + return { current, daily, hourly } +} diff --git a/src/main.tsx b/src/main.tsx index 76e4663..6cc6933 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,7 +7,7 @@ import { applyTheme } from './lib/theme' import { useStore } from './lib/store' // Apply the saved theme before first paint to avoid a flash. -applyTheme(useStore.getState().settings.theme, useStore.getState().settings.accent) +applyTheme(useStore.getState().settings.theme, useStore.getState().settings.accent, useStore.getState().settings.customBg) ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/screens/Auth.tsx b/src/screens/Auth.tsx index 80f0e80..c80e6a7 100644 --- a/src/screens/Auth.tsx +++ b/src/screens/Auth.tsx @@ -1,29 +1,43 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { ArrowLeft, Sparkles } from 'lucide-react' -import { signIn, signUp } from '../lib/auth' +import { ArrowLeft, Sparkles, Check, X } from 'lucide-react' +import { signIn, signUp, resetPassword, usernameAvailable, validUsername } from '../lib/auth' import { firebaseReady } from '../lib/firebase' import { Input, Button } from '../components/ui' export default function Auth() { const nav = useNavigate() const [mode, setMode] = useState<'in' | 'up'>('up') + const [identifier, setIdentifier] = useState('') // email or username (sign in) const [email, setEmail] = useState('') const [pass, setPass] = useState('') const [username, setUsername] = useState('') + const [uState, setUState] = useState<'idle' | 'checking' | 'free' | 'taken' | 'invalid'>('idle') const [err, setErr] = useState('') + const [msg, setMsg] = useState('') const [busy, setBusy] = useState(false) + // Live username availability (Instagram-style) + useEffect(() => { + if (mode !== 'up' || !username) { setUState('idle'); return } + if (!validUsername(username)) { setUState('invalid'); return } + setUState('checking') + const t = setTimeout(async () => { + setUState((await usernameAvailable(username)) ? 'free' : 'taken') + }, 450) + return () => clearTimeout(t) + }, [username, mode]) + async function submit() { - setErr('') - if (!firebaseReady) { setErr('Connect Firebase first (Settings → Connect account).'); return } + setErr(''); setMsg('') + if (!firebaseReady) { setErr('Connect Firebase first (Settings → Account).'); return } setBusy(true) try { if (mode === 'up') { - if (username.trim().length < 3) throw new Error('Username must be 3+ characters') + if (uState === 'taken') throw new Error('That username is already taken') await signUp(email.trim(), pass, username.trim()) } else { - await signIn(email.trim(), pass) + await signIn(identifier, pass) } nav('/chat') } catch (e: any) { @@ -33,25 +47,55 @@ export default function Auth() { } } + async function forgot() { + setErr(''); setMsg('') + const target = mode === 'in' ? identifier : email + if (!target.includes('@')) { setErr('Enter your email above to reset your password.'); return } + try { + await resetPassword(target) + setMsg('Password reset email sent — check your inbox.') + } catch (e: any) { + setErr(e?.message?.replace('Firebase:', '').trim() || 'Could not send reset email') + } + } + return (
-
+ Vintly

Vintly

{mode === 'up' ? 'Create your account' : 'Welcome back'}

- {mode === 'up' && setUsername(e.target.value)} />} - setEmail(e.target.value)} /> + {mode === 'up' ? ( + <> +
+ setUsername(e.target.value.toLowerCase())} /> + {username && ( +

+ {uState === 'checking' && 'Checking…'} + {uState === 'free' && <> @{username} is available} + {uState === 'taken' && <> @{username} is taken} + {uState === 'invalid' && '3–20 chars: letters, numbers, . or _'} +

+ )} +
+ setEmail(e.target.value)} /> + + ) : ( + setIdentifier(e.target.value)} /> + )} setPass(e.target.value)} /> {err &&

{err}

} + {msg &&

{msg}

} + {mode === 'in' && }
- diff --git a/src/screens/Bin.tsx b/src/screens/Bin.tsx new file mode 100644 index 0000000..6a95653 --- /dev/null +++ b/src/screens/Bin.tsx @@ -0,0 +1,47 @@ +import { Link } from 'react-router-dom' +import { ArrowLeft, Trash2, RotateCcw, Trash } from 'lucide-react' +import { useStore } from '../lib/store' +import { Card, Button, EmptyState } from '../components/ui' +import { fontClass } from './NoteEditor' + +export default function Bin() { + const { notes, restoreNote, deleteNote } = useStore() + const trashed = notes.filter((n) => n.trashed).sort((a, b) => (b.trashedAt || 0) - (a.trashedAt || 0)) + + function emptyBin() { + if (!confirm('Permanently delete all notes in the bin? This cannot be undone.')) return + trashed.forEach((n) => deleteNote(n.id)) + } + + return ( +
+
+ +

Bin

+ {trashed.length > 0 && } +
+ + {trashed.length === 0 ? ( + } title="Bin is empty" hint="Deleted notes appear here so you can restore them." /> + ) : ( + <> +

Notes here can be restored, or deleted forever.

+
+ {trashed.map((n) => ( + + {n.title &&

{n.title}

} + {n.isChecklist + ?

{n.checklist.length} list items

+ : n.body &&
} +
+ + +
+ + ))} +
+ + )} +
+ ) +} diff --git a/src/screens/Calendar.tsx b/src/screens/Calendar.tsx index 92ed864..89bfe62 100644 --- a/src/screens/Calendar.tsx +++ b/src/screens/Calendar.tsx @@ -1,12 +1,14 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, addMonths, isSameMonth, isSameDay, format, } from 'date-fns' -import { ChevronLeft, ChevronRight, Plus, Trash2, Clock } from 'lucide-react' +import { ChevronLeft, ChevronRight, Plus, Trash2, Clock, PartyPopper, Landmark } from 'lucide-react' import { useStore } from '../lib/store' import { Sheet, Input, Button } from '../components/ui' -import { scheduleReminder } from '../lib/notifications' +import { scheduleReminder, ensureNotificationPermission } from '../lib/notifications' +import { holidaysOn } from '../lib/holidays' +import { hexToRgbTriple } from '../lib/theme' const EVENT_COLORS = ['124 92 255', '16 185 129', '244 63 94', '245 158 11', '14 165 233'] @@ -19,6 +21,21 @@ export default function CalendarScreen() { const [time, setTime] = useState('09:00') const [color, setColor] = useState(EVENT_COLORS[0]) const [remind, setRemind] = useState(true) + const [dir, setDir] = useState<'next' | 'prev'>('next') + const swipe = useRef<{ x: number; y: number } | null>(null) + + function changeMonth(delta: number) { + setDir(delta > 0 ? 'next' : 'prev') + setCursor((c) => addMonths(c, delta)) + } + function onTouchStart(e: React.TouchEvent) { swipe.current = { x: e.touches[0].clientX, y: e.touches[0].clientY } } + function onTouchEnd(e: React.TouchEvent) { + if (!swipe.current) return + const dx = e.changedTouches[0].clientX - swipe.current.x + const dy = e.changedTouches[0].clientY - swipe.current.y + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) changeMonth(dx < 0 ? 1 : -1) + swipe.current = null + } const monthStart = startOfMonth(cursor) const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }) @@ -37,10 +54,13 @@ export default function CalendarScreen() { dt.setHours(h, m, 0, 0) addEvent({ title: title.trim(), date: dt.getTime(), color, remindMinsBefore: remind ? 10 : undefined }) if (remind) { + await ensureNotificationPermission() + // Remind 10 min before — or right at start time if the event is very soon. + const remindAt = dt.getTime() - 10 * 60000 > Date.now() ? new Date(dt.getTime() - 10 * 60000) : dt await scheduleReminder({ - title: `Upcoming: ${title.trim()}`, + title: `📅 ${title.trim()}`, body: `Starts at ${format(dt, 'p')}`, - at: new Date(dt.getTime() - 10 * 60000), + at: remindAt, }) } setTitle('') @@ -52,17 +72,24 @@ export default function CalendarScreen() {

{format(cursor, 'MMMM yyyy')}

- - + +
-
- {['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((d, i) =>
{d}
)} -
-
+
+
+ {['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((d, i) =>
{d}
)} +
+
{days.map((d) => { const has = events.some((e) => isSameDay(new Date(e.date), d)) + const hol = holidaysOn(d) const isSel = isSameDay(d, selected) const isToday = isSameDay(d, new Date()) return ( @@ -70,14 +97,20 @@ export default function CalendarScreen() { key={d.toISOString()} onClick={() => setSelected(d)} className={`relative aspect-square rounded-2xl text-sm transition ${ - isSel ? 'bg-brand text-white font-bold' : isToday ? 'bg-brand/15 text-brand' : isSameMonth(d, cursor) ? 'text-ink' : 'text-muted/40' + isSel ? 'bg-brand text-white font-bold' : isToday ? 'bg-brand/15 text-brand' : hol.length && isSameMonth(d, cursor) ? 'text-amber-400 font-semibold' : isSameMonth(d, cursor) ? 'text-ink' : 'text-muted/40' }`} > {format(d, 'd')} - {has && !isSel && } + {!isSel && (has || hol.length > 0) && ( + + {has && } + {hol.length > 0 && } + + )} ) })} +
@@ -86,7 +119,18 @@ export default function CalendarScreen() {
- {dayEvents.length === 0 &&

No events. Add one to plan your day.

} + {holidaysOn(selected).map((h, i) => ( +
+ + {h.type === 'national' ? : } + +
+

{h.name}

+

{h.type === 'national' ? 'Public holiday' : 'Festival'}

+
+
+ ))} + {dayEvents.length === 0 && holidaysOn(selected).length === 0 &&

No events. Add one to plan your day.

} {dayEvents.map((e) => (
@@ -106,10 +150,14 @@ export default function CalendarScreen() { setTime(e.target.value)} className="rounded-2xl bg-surface border border-line px-4 py-3 text-ink" />
-
+
{EVENT_COLORS.map((c) => (