diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e135d9..e83465b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Dedicated Tasks page (`/tasks`) — task list, NewTaskForm, and End Day button moved out of the dashboard column into a standalone page; starting a day auto-navigates to `/tasks`; day summary displayed on the Tasks page after ending the day; dashboard shows a compact "Day In Progress" card with task count and a "View Tasks" link while a day is active - — `src/pages/TaskList.tsx` (new), `src/pages/Index.tsx`, `src/App.tsx` +- Apple HIG compliance pass for the native iOS app + - **Bottom sheets** — `TaskEditDialog`, `StartDayDialog`, `ArchiveEditDialog`, and `DeleteConfirmationDialog` now render as swipe-to-dismiss vaul `Drawer` sheets on iOS (snap points tuned per dialog complexity) and fall back to the existing centered Radix `Dialog` on web. `DeleteConfirmationDialog` reverses button order on iOS (destructive action above Cancel) per UIAlertController convention. + — `src/components/ui/adaptive-dialog.tsx` (new), `src/components/ui/drawer.tsx`, `src/components/TaskEditDialog.tsx`, `src/components/StartDayDialog.tsx`, `src/components/ArchiveEditDialog.tsx`, `src/components/DeleteConfirmationDialog.tsx` + - **Haptic feedback** — `useHaptics` wraps `@capacitor/haptics`; light impact on tab switches and Edit, medium on Delete, heavy on destructive confirm, success notification on task creation and day archive, error notification on sync failure. No-op on web. + — `src/hooks/useHaptics.ts` (new), `src/components/MobileNav.tsx`, `src/components/TaskItem.tsx`, `src/components/DeleteConfirmationDialog.tsx`, `src/contexts/TimeTrackingContext.tsx` + - **App lifecycle persistence** — `useAppLifecycle` uses `@capacitor/app`'s `appStateChange` event (fires at the Swift layer before WKWebView freezes) instead of `visibilitychange` for the emergency localStorage backup, eliminating the race condition on rapid app backgrounding. Falls back to `visibilitychange` on web. + — `src/hooks/useAppLifecycle.ts` (new), `src/contexts/TimeTrackingContext.tsx` + - **Status bar theming** — `useStatusBar` syncs the iOS status bar text colour (white in dark mode, black in light mode) via `@capacitor/status-bar`; `apple-mobile-web-app-status-bar-style` updated to `black-translucent` so the web view extends behind the status bar region. No-op on web. + — `src/hooks/useStatusBar.ts` (new), `src/App.tsx`, `index.html` + - **iOS navigation header** — desktop `SiteNavigationMenu` is hidden on iOS builds and replaced with `IosPageHeader`: a sticky 17px SF-style title bar with safe-area-inset-top padding, back chevron, and right-side action slot. `ios-build` class added to `` on iOS to prevent double-stacking of safe-area padding. + — `src/components/IosPageHeader.tsx` (new), `src/components/PageLayout.tsx`, `src/main.tsx`, `public/pwa.css` + - **Keyboard avoidance** — `@capacitor/keyboard` configured with `resize: body` so the viewport shrinks above the keyboard. `useKeyboardHeight` hook tracks keyboard height and applies it as `paddingBottom` on `DrawerContent` so form fields inside bottom sheets remain accessible. `scroll-margin-bottom: 24px` added for native scroll-into-view on input focus. + — `src/hooks/useKeyboardHeight.ts` (new), `capacitor.config.ts`, `src/components/ui/drawer.tsx`, `public/pwa.css` + - **Long-press context menus** — `useLongPress` fires a 500 ms hold callback; `TaskItem` wraps cards in a Radix `ContextMenu` (right-click on desktop, long-press on iOS) with Edit and Delete actions. Action buttons hidden on iOS builds where context menus serve as the primary affordance. + — `src/hooks/useLongPress.ts` (new), `src/components/TaskItem.tsx` + - **Page transition animations** — route changes in the iOS build play a subtle 280 ms slide-in from the right (`cubic-bezier(0.25, 0.46, 0.45, 0.94)`), scoped to `@supports (-webkit-touch-callout: none)` so the animation never runs on web. + — `src/App.tsx` (`AnimatedRoutes` component), `public/pwa.css` + - **New Capacitor plugins** installed: `@capacitor/app`, `@capacitor/haptics`, `@capacitor/status-bar`, `@capacitor/keyboard` (all v8.x, matching the existing core/ios versions). + +### Changed + +- **Touch targets** — `Button` `size="sm"` raised from `h-9` (36 px) to `h-10` (40 px); mobile CSS now enforces `min-height: 44px` on all non-hidden buttons at ≤768 px (previously commented out). + — `src/components/ui/button.tsx`, `public/pwa.css` +- **Rubber-band scroll bounce** — `overscroll-behavior-y` restored to `auto` on `#root` inside the iOS `@supports` block so the native bounce animation works again (was `contain` globally which suppressed it). vaul drawer elements gain `overscroll-behavior: contain` + `touch-action: pan-y` to prevent scroll bleed through open sheets. + — `public/pwa.css` + + - Tasks navigation item added to desktop top nav and mobile bottom nav; mobile nav grid updated to support up to five items for authenticated users — `src/components/Navigation.tsx`, `src/components/MobileNav.tsx` diff --git a/CLAUDE.md b/CLAUDE.md index 9cba0e5..4a8dbc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md - AI Assistant Codebase Guide -**Last Updated:** 2026-04-26 -**Version:** 2.2.0 +**Last Updated:** 2026-05-14 +**Version:** 2.3.0 Timetraked is a React 18 + TypeScript time tracking PWA for freelancers and consultants, with dual storage (localStorage guest mode and optional Supabase cloud sync). A native iOS app is also available via Capacitor. @@ -31,7 +31,7 @@ After implementing changes, run lint and tests before considering a task complet | Forms | React Hook Form + Zod | | Backend | Supabase (optional) or localStorage | | PWA | Vite PWA Plugin + Workbox | -| Native iOS | Capacitor 8 (@capacitor/core + @capacitor/ios) | +| Native iOS | Capacitor 8 (@capacitor/core + @capacitor/ios + @capacitor/app + @capacitor/haptics + @capacitor/status-bar + @capacitor/keyboard) | | Testing | Vitest + React Testing Library + Playwright | --- @@ -70,8 +70,15 @@ export const MyComponent = () => { | `src/lib/supabase.ts` | Supabase client configuration and caching | | `src/config/categories.ts` | Default category definitions | | `src/config/projects.ts` | Default project definitions | -| `src/components/PageLayout.tsx` | Shared page chrome (title + optional actions slot) | -| `capacitor.config.ts` | Capacitor iOS configuration | +| `src/components/PageLayout.tsx` | Shared page chrome (title + optional actions slot); renders `IosPageHeader` on iOS | +| `src/components/IosPageHeader.tsx` | iOS-only sticky nav bar with safe-area-inset-top, back chevron, and action slot | +| `src/components/ui/adaptive-dialog.tsx` | Renders vaul `Drawer` on iOS, Radix `Dialog` on web | +| `src/hooks/useHaptics.ts` | `@capacitor/haptics` wrapper (light/medium/heavy, success/error) | +| `src/hooks/useStatusBar.ts` | `@capacitor/status-bar` wrapper — syncs bar style with dark/light mode | +| `src/hooks/useAppLifecycle.ts` | `@capacitor/app` appStateChange hook for reliable background persistence | +| `src/hooks/useKeyboardHeight.ts` | `@capacitor/keyboard` reactive height for bottom-sheet form padding | +| `src/hooks/useLongPress.ts` | 500 ms hold detector for context menu trigger on touch | +| `capacitor.config.ts` | Capacitor iOS configuration (Keyboard resize plugin configured here) | | `.env.ios` | iOS build env (VITE_IOS_BUILD=true, no Supabase) | --- @@ -92,6 +99,23 @@ The app ships as both a PWA and a native iOS app via Capacitor 8. - Routing uses `HashRouter` (required — Capacitor loads from filesystem, not a server) - CSP includes `capacitor://localhost` for WKWebView asset loading - Data storage is localStorage-only (no Supabase keys in `.env.ios`) +- Desktop `SiteNavigationMenu` is hidden; `IosPageHeader` renders instead (sticky, safe-area-aware, back chevron) +- All edit/confirm dialogs (`TaskEditDialog`, `StartDayDialog`, `ArchiveEditDialog`, `DeleteConfirmationDialog`) become bottom sheets via `AdaptiveDialog`; on web the existing Radix Dialog renders unchanged +- Haptic feedback fires on every meaningful interaction via `useHaptics` +- `@capacitor/app` `appStateChange` event used for emergency data persistence (more reliable than `visibilitychange`) +- `@capacitor/status-bar` syncs status bar text colour with system dark/light mode +- `@capacitor/keyboard` configured with `resize: body`; `useKeyboardHeight` lifts bottom-sheet content above the keyboard +- Long-press on task cards opens a context menu (Edit / Delete); on-card action buttons are hidden + +**Installed Capacitor plugins** (all v8.x): + +| Package | Purpose | +| ------- | ------- | +| `@capacitor/core` + `@capacitor/ios` | Core bridge (pre-existing) | +| `@capacitor/app` | Native app lifecycle events (pause/resume) | +| `@capacitor/haptics` | Tactile feedback | +| `@capacitor/status-bar` | Status bar style control | +| `@capacitor/keyboard` | Keyboard height events and viewport resize | **iOS npm scripts:** @@ -114,6 +138,9 @@ When working on iOS/Capacitor projects, remember that `cap sync` overwrites Pack - Gate any web-only UI (PWA install, auth, sync) behind `import.meta.env.VITE_IOS_BUILD !== "true"` - Avoid `window.location.reload()` in iOS paths — use `window.location.replace()` to avoid interrupting the Capacitor JS bridge - Test localStorage-only flow (no Supabase) before marking iOS features complete +- For new dialogs/modals: use `AdaptiveDialog` (`src/components/ui/adaptive-dialog.tsx`) instead of `Dialog` directly — it automatically renders a bottom sheet on iOS +- Add haptic feedback for new interactions via `useHaptics` (`src/hooks/useHaptics.ts`): `lightImpact` for navigation/selection, `mediumImpact` for intent to delete, `heavyImpact` for confirmed destructive actions, `successNotify`/`errorNotify` for outcomes +- All new Capacitor plugin calls should be gated with `Capacitor.isNativePlatform()` or imported dynamically (see existing hooks for the pattern) so the web build never fails at runtime on missing native APIs --- diff --git a/README-EXT.md b/README-EXT.md index c356eb7..eac1af5 100644 --- a/README-EXT.md +++ b/README-EXT.md @@ -163,6 +163,21 @@ Task descriptions support **GitHub Flavored Markdown (GFM)**: **Native-Like Experience:** Standalone window, app icon, splash screen on launch. +### iOS Native App (Capacitor) + +The Capacitor build (`VITE_IOS_BUILD=true`) includes additional Apple HIG enhancements that are inactive in the PWA: + +| Feature | Detail | +| ------- | ------- | +| **Bottom sheets** | All edit/confirm dialogs slide up as swipe-to-dismiss sheets instead of centered overlays | +| **Haptic feedback** | Light impact on navigation taps, medium on destructive intent, success/error notifications on outcomes | +| **Status bar theming** | Status bar text colour tracks light/dark mode; content extends behind the status bar via `black-translucent` | +| **iOS navigation header** | Sticky 17 px title bar with safe-area-inset-top padding and back chevron replaces the desktop nav bar | +| **Keyboard avoidance** | Viewport shrinks above the software keyboard; bottom sheet forms scroll above it automatically | +| **Long-press context menus** | Hold a task card to reveal Edit / Delete without on-card buttons cluttering the layout | +| **Page transitions** | Subtle 280 ms slide-in animation on route changes, matching the iOS push-navigation idiom | +| **Rubber-band bounce** | Native scroll bounce restored on the main scroll container | + --- ## Authentication & Storage @@ -180,7 +195,7 @@ Timetraked uses an **action-triggered save** approach optimized for single-devic 1. **In-Memory First** — changes update React state immediately. 2. **Action Saves** — every task mutation (start, update, delete) and day lifecycle event (start day, end day) triggers an immediate `saveCurrentDay()` call with the freshly computed state, keeping localStorage and Supabase in sync without a debounce delay. -3. **Emergency Backups** — `visibilitychange` (iOS app backgrounding) and `beforeunload` (browser close) write a synchronous localStorage snapshot as a last-resort fallback before JavaScript execution is suspended. +3. **Emergency Backups** — on iOS, `@capacitor/app`'s `appStateChange` event fires at the Swift layer before WKWebView freezes, giving a reliable save window; on web, `visibilitychange` and `beforeunload` write a synchronous localStorage snapshot as a last-resort fallback before JavaScript execution is suspended. 4. **Manual Sync** — the sync button in the navigation saves all data types (tasks, projects, categories, archived days, todos) in one batch, useful after recovering from an error. When you sign in, your `localStorage` data automatically migrates to Supabase (timestamps compared to prevent overwriting newer data, no data loss). When you sign out, Supabase data syncs back to `localStorage`. diff --git a/README.md b/README.md index 95c3100..54af6db 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A Progressive Web App (PWA) for time tracking built with React, TypeScript, and - **CSV Import** — bring in existing time data from other tools - **Weekly Report** — AI-generated work summaries (standup, client, or retrospective tone) - **No Account Required** — full functionality with local storage; optional cloud sync via Supabase -- **PWA + Native iOS** — installable on desktop/mobile; distributed as a native iOS app via Capacitor 8 +- **PWA + Native iOS** — installable on desktop/mobile; distributed as a native iOS app via Capacitor 8 with Apple HIG-compliant bottom sheets, haptic feedback, status bar theming, keyboard avoidance, and native page transitions --- diff --git a/capacitor.config.ts b/capacitor.config.ts index a65c557..a761423 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -26,7 +26,13 @@ const config: CapacitorConfig = { }, plugins: { - // Placeholder — native plugin config goes here in Phase 4 (widget bridge) + Keyboard: { + // Shrink the body when the keyboard appears so fixed-bottom UI (MobileNav, + // bottom sheets) moves up with the keyboard automatically. + resize: 'body', + style: 'default', + resizeOnFullScreen: true + } } }; diff --git a/index.html b/index.html index b764c49..24c0ac4 100644 --- a/index.html +++ b/index.html @@ -25,7 +25,7 @@ - + diff --git a/package-lock.json b/package-lock.json index 53bc9bc..6cbee5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,12 @@ "name": "timetraked", "version": "0.45.0", "dependencies": { + "@capacitor/app": "^8.1.0", "@capacitor/core": "^8.3.1", + "@capacitor/haptics": "^8.0.2", "@capacitor/ios": "^8.3.1", + "@capacitor/keyboard": "^8.0.3", + "@capacitor/status-bar": "^8.0.2", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -1722,6 +1726,15 @@ "node": ">=6.9.0" } }, + "node_modules/@capacitor/app": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz", + "integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/cli": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.1.tgz", @@ -1764,6 +1777,15 @@ "tslib": "^2.1.0" } }, + "node_modules/@capacitor/haptics": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz", + "integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/ios": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.1.tgz", @@ -1773,6 +1795,24 @@ "@capacitor/core": "^8.3.0" } }, + "node_modules/@capacitor/keyboard": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz", + "integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz", + "integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", diff --git a/package.json b/package.json index bf42e97..a0ab759 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,12 @@ "screenshots:headed": "playwright test screenshots.spec.ts --headed" }, "dependencies": { + "@capacitor/app": "^8.1.0", "@capacitor/core": "^8.3.1", + "@capacitor/haptics": "^8.0.2", "@capacitor/ios": "^8.3.1", + "@capacitor/keyboard": "^8.0.3", + "@capacitor/status-bar": "^8.0.2", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -81,6 +85,7 @@ "@rollup/plugin-terser": ">=0.4.4" }, "devDependencies": { + "@capacitor/cli": "^8.3.1", "@eslint/js": "^9.39.4", "@playwright/test": "^1.56.1", "@tailwindcss/typography": "^0.5.15", @@ -88,7 +93,6 @@ "@testing-library/react": "^14.3.1", "@types/node": "^22.19.17", "@types/react": "^18.3.28", - "@capacitor/cli": "^8.3.1", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.21", diff --git a/public/pwa.css b/public/pwa.css index b020bce..6a8f6b7 100644 --- a/public/pwa.css +++ b/public/pwa.css @@ -15,11 +15,17 @@ body { padding-right: var(--safe-area-inset-right); } +/* IosPageHeader consumes the safe-area-inset-top itself via paddingTop inline style. + Prevent double-stacking when running as a Capacitor iOS build. */ +.ios-build body { + padding-top: 0; +} + /* Add padding bottom for mobile nav on mobile devices */ /* Note: iOS-specific padding is handled in the iOS section below */ @media (max-width: 768px) { body:not(.ios-device) { - padding-bottom: calc(4rem + var(--safe-area-inset-bottom)); + padding-bottom: calc(3rem + var(--safe-area-inset-bottom)); } } @@ -85,23 +91,24 @@ button, } } -/* Pull-to-refresh prevention (optional, can be enabled if needed) */ +/* Prevent pull-to-refresh on non-iOS browsers */ body { overscroll-behavior-y: contain; } +/* Restore native rubber-band bounce on iOS — body is fixed/overflow:hidden there + so this applies to #root (the actual scroll container). */ +@supports (-webkit-touch-callout: none) { + #root { + overscroll-behavior-y: auto; + } +} + /* Improve button touch targets on mobile */ @media (max-width: 768px) { - /* button:not(.icon-only) { */ - /* min-height: 44px; */ - /* min-width: 44px; */ - /* padding: 0.75rem 1rem; */ - /* } */ - - /* Larger tap targets for icon buttons */ - button.icon-only { - min-width: 44px; + button:not([aria-hidden="true"]) { min-height: 44px; + min-width: 44px; } } @@ -138,6 +145,15 @@ body { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } +/* Ensure focused inputs scroll into view above the keyboard with enough clearance */ +@supports (-webkit-touch-callout: none) { + input:focus, + textarea:focus, + select:focus { + scroll-margin-bottom: 24px; + } +} + /* iOS specific fixes */ @supports (-webkit-touch-callout: none) { @@ -198,6 +214,37 @@ body { } } +/* Prevent scroll from leaking through open bottom sheets to the page beneath */ +[data-vaul-drawer] { + touch-action: pan-y; + overscroll-behavior: contain; +} + +/* Lift the floating action button above the mobile nav + home indicator on iOS */ +@supports (-webkit-touch-callout: none) { + .fab-nav-offset { + bottom: calc(4rem + env(safe-area-inset-bottom, 0px)) !important; + } +} + +/* iOS page transition: subtle slide-in from the right on route change */ +@supports (-webkit-touch-callout: none) { + .page-transition-enter { + animation: iosSlideIn 280ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + } + + @keyframes iosSlideIn { + from { + transform: translateX(30px); + opacity: 0.8; + } + to { + transform: translateX(0); + opacity: 1; + } + } +} + /* Android specific fixes */ @media (max-width: 768px) { diff --git a/src/App.tsx b/src/App.tsx index 80e2032..335ebbd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,15 @@ import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { HashRouter, Routes, Route, Navigate } from "react-router-dom"; +import { HashRouter, Routes, Route, Navigate, useLocation } from "react-router-dom"; import { AuthProvider } from "@/contexts/AuthContext"; import { OfflineProvider } from "@/contexts/OfflineContext"; import { TimeTrackingProvider } from "@/contexts/TimeTrackingContext"; import { useAuth } from "@/hooks/useAuth"; -import { Suspense, lazy } from "react"; +import { Suspense, lazy, useState, useEffect } from "react"; import { InstallPrompt } from "@/components/InstallPrompt"; import { UpdateNotification } from "@/components/UpdateNotification"; +import { useStatusBar } from "@/hooks/useStatusBar"; const isIosBuild = import.meta.env.VITE_IOS_BUILD === "true"; import { MobileNav } from "@/components/MobileNav"; @@ -38,26 +39,50 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { return <>{children}; }; +const AppShell = () => { + const [isDark, setIsDark] = useState( + () => window.matchMedia("(prefers-color-scheme: dark)").matches + ); + useEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = (e: MediaQueryListEvent) => setIsDark(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + useStatusBar(isDark); + return null; +}; + +const AnimatedRoutes = () => { + const location = useLocation(); + return ( +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + +
+ ); +}; + const App = () => ( + }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - + diff --git a/src/components/ArchiveEditDialog.tsx b/src/components/ArchiveEditDialog.tsx index f4b41cf..ea5d39e 100644 --- a/src/components/ArchiveEditDialog.tsx +++ b/src/components/ArchiveEditDialog.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect } from "react"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; + AdaptiveDialog, + AdaptiveDialogContent, + AdaptiveDialogHeader, + AdaptiveDialogTitle, +} from "@/components/ui/adaptive-dialog"; import { AlertDialog, AlertDialogAction, @@ -260,16 +260,16 @@ export const ArchiveEditDialog: React.FC = ({ }; return ( - - - + + +
- + {formatDate(day.startTime)} - +
-
+
@@ -621,7 +621,7 @@ export const ArchiveEditDialog: React.FC = ({ - -
+ + ); }; diff --git a/src/components/DeleteConfirmationDialog.tsx b/src/components/DeleteConfirmationDialog.tsx index 4ef48df..ddd4f02 100644 --- a/src/components/DeleteConfirmationDialog.tsx +++ b/src/components/DeleteConfirmationDialog.tsx @@ -1,45 +1,91 @@ -import React from 'react'; +import React from "react"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle -} from '@/components/ui/alert-dialog'; + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + AdaptiveDialog, + AdaptiveDialogContent, + AdaptiveDialogHeader, + AdaptiveDialogTitle, + AdaptiveDialogDescription, + AdaptiveDialogFooter, +} from "@/components/ui/adaptive-dialog"; +import { Button } from "@/components/ui/button"; +import { useHaptics } from "@/hooks/useHaptics"; + +const isIosBuild = import.meta.env.VITE_IOS_BUILD === "true"; interface DeleteConfirmationDialogProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - taskTitle: string; + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + taskTitle: string; } export const DeleteConfirmationDialog: React.FC< - DeleteConfirmationDialogProps + DeleteConfirmationDialogProps > = ({ isOpen, onClose, onConfirm, taskTitle }) => { - return ( - - - - Delete Task - - Are you sure you want to delete "{taskTitle}"? This action cannot be - undone. - - - - Cancel - - Delete - - - - - ); + const { heavyImpact } = useHaptics(); + + if (isIosBuild) { + return ( + + + + Delete Task + + Are you sure you want to delete “{taskTitle}”? This action cannot be + undone. + + + + {/* Destructive action first on iOS (action-sheet convention) */} + + + + + + ); + } + + return ( + + + + Delete Task + + Are you sure you want to delete “{taskTitle}”? This action cannot be + undone. + + + + Cancel + + Delete + + + + + ); }; diff --git a/src/components/IosPageHeader.tsx b/src/components/IosPageHeader.tsx new file mode 100644 index 0000000..22e8e47 --- /dev/null +++ b/src/components/IosPageHeader.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { ChevronLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface IosPageHeaderProps { + title?: ReactNode; + actions?: ReactNode; + /** Show back chevron. Pass true to use history.back(), or a path string to navigate there. */ + back?: boolean | string; +} + +export const IosPageHeader = ({ title, actions, back }: IosPageHeaderProps) => { + const navigate = useNavigate(); + + const handleBack = () => { + if (typeof back === "string") { + navigate(back); + } else { + navigate(-1); + } + }; + + return ( +
+
+ {back ? ( + + ) : ( +
+ )} + + {title && ( +

+ {title} +

+ )} + + {actions ? ( +
+ {actions} +
+ ) : ( +
+ )} +
+
+ ); +}; diff --git a/src/components/MobileNav.tsx b/src/components/MobileNav.tsx index 8c04b2e..63a70b5 100644 --- a/src/components/MobileNav.tsx +++ b/src/components/MobileNav.tsx @@ -2,10 +2,12 @@ import { memo } from "react"; import { Link, useLocation } from "react-router-dom"; import { Home, Archive, Settings, PaperclipIcon, ClipboardList } from "lucide-react"; import { useAuth } from "@/hooks/useAuth"; +import { useHaptics } from "@/hooks/useHaptics"; export const MobileNav = memo(function MobileNav() { const location = useLocation(); const { isAuthenticated } = useAuth(); + const { lightImpact } = useHaptics(); const isActive = (path: string) => { return location.pathname === path; @@ -53,11 +55,12 @@ export const MobileNav = memo(function MobileNav() { paddingBottom: "max(env(safe-area-inset-bottom, 0px), 0px)" }} > -
+
{navItems.map(({ path, icon: Icon, label }) => ( = ({ onSubmit, defaultOpen @@ -107,8 +107,8 @@ export const StartDayDialog: React.FC = ({ Start Day - - - + + + ); }; diff --git a/src/components/TaskEditDialog.tsx b/src/components/TaskEditDialog.tsx index 32bba35..606a686 100644 --- a/src/components/TaskEditDialog.tsx +++ b/src/components/TaskEditDialog.tsx @@ -1,11 +1,12 @@ import React, { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog'; + AdaptiveDialog, + AdaptiveDialogContent, + AdaptiveDialogFooter, + AdaptiveDialogHeader, + AdaptiveDialogTitle, +} from "@/components/ui/adaptive-dialog"; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -205,15 +206,16 @@ export const TaskEditDialog: React.FC = ({ }; return ( - - - - + + + + Edit Task - - + + +
@@ -454,21 +456,22 @@ export const TaskEditDialog: React.FC = ({ - {/* Action Buttons */} -
- - -
- -
+
+ + + + + + + ); }; diff --git a/src/components/TaskItem.tsx b/src/components/TaskItem.tsx index e1a4b19..873f21f 100644 --- a/src/components/TaskItem.tsx +++ b/src/components/TaskItem.tsx @@ -1,7 +1,16 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; import { Task } from "@/contexts/TimeTrackingContext"; import { useTimeTracking } from "@/hooks/useTimeTracking"; import { Button } from "@/components/ui/button"; +import { useHaptics } from "@/hooks/useHaptics"; +import { useLongPress } from "@/hooks/useLongPress"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Card, CardContent } from "@/components/ui/card"; import { TaskEditDialog } from "@/components/TaskEditDialog"; import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog"; @@ -32,12 +41,29 @@ export const TaskItem: React.FC = ({ const { categories } = useTimeTracking(); const [showEditDialog, setShowEditDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const { lightImpact, mediumImpact } = useHaptics(); + const contextMenuTriggerRef = useRef(null); + + const longPressHandlers = useLongPress(() => { + mediumImpact(); + // Simulate a right-click to open the Radix context menu programmatically + if (contextMenuTriggerRef.current) { + contextMenuTriggerRef.current.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true }) + ); + } + }); const duration = task.duration || (isActive ? currentDuration : 0); const category = categories.find((c) => c.id === task.category); + const isIosBuild = import.meta.env.VITE_IOS_BUILD === "true"; + return ( <> + + +
= ({
-
- - -
+ {!isIosBuild && ( +
+ + +
+ )} + + + + { lightImpact(); setShowEditDialog(true); }} + > + + Edit Task + + + { mediumImpact(); setShowDeleteDialog(true); }} + className="text-destructive focus:text-destructive" + > + + Delete Task + + + void + children: React.ReactNode + /** vaul snap points, iOS only */ + snapPoints?: (number | string)[] +} + +export const AdaptiveDialog = ({ + open, + onOpenChange, + children, + snapPoints, +}: AdaptiveDialogProps) => { + const [activeSnapPoint, setActiveSnapPoint] = React.useState( + snapPoints?.[0] ?? null + ) + + React.useEffect(() => { + if (open) { + setActiveSnapPoint(snapPoints?.[0] ?? null) + } + }, [open]) // snapPoints are static per dialog instance + + if (isIosBuild) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} + +interface AdaptiveDialogContentProps { + children: React.ReactNode + className?: string +} + +export const AdaptiveDialogContent = ({ + children, + className, +}: AdaptiveDialogContentProps) => { + if (isIosBuild) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} + +export const AdaptiveDialogHeader = ({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) => { + if (isIosBuild) { + return {children} + } + return {children} +} + +export const AdaptiveDialogTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ children, className, ...props }, ref) => { + if (isIosBuild) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +}) +AdaptiveDialogTitle.displayName = "AdaptiveDialogTitle" + +export const AdaptiveDialogDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ children, className, ...props }, ref) => { + if (isIosBuild) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +}) +AdaptiveDialogDescription.displayName = "AdaptiveDialogDescription" + +export const AdaptiveDialogFooter = ({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) => { + if (isIosBuild) { + return ( + + {children} + + ) + } + return {children} +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 2fb575c..27d7840 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -22,7 +22,7 @@ const buttonVariants = cva( }, size: { default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", + sm: "h-10 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", }, diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 775c0bd..37c5d8b 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -60,7 +60,7 @@ const DialogHeader = ({ }: React.HTMLAttributes) => (
, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - -
- {children} - - -)) +>(({ className, children, style, ...props }, ref) => { + const keyboardHeight = useKeyboardHeight(); + return ( + + + 0 ? keyboardHeight : undefined, ...style }} + {...props} + > +
+ {children} + + + ); +}) DrawerContent.displayName = "DrawerContent" const DrawerHeader = ({ diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index 2cd7a67..0620ade 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -27,6 +27,8 @@ import { } from '@/utils/exportUtils'; import { parseTaskChecklist } from '@/utils/checklistUtils'; import { SCHEMA_VERSION } from '@/services/localStorageService'; +import { useAppLifecycle } from '@/hooks/useAppLifecycle'; +import { useHaptics } from '@/hooks/useHaptics'; export interface Task { id: string; @@ -229,6 +231,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const [lastSyncTime, setLastSyncTime] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const { successNotify, errorNotify } = useHaptics(); + // Debounce refs to manage timeouts const saveTimeoutRef = useRef(null); const currentTaskTimeoutRef = useRef(null); @@ -425,6 +429,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ "❌ Manual sync partially failed:", failed.map((f) => (f as PromiseRejectedResult).reason) ); + errorNotify(); // Do not mark sync as successful when any save failed return; } @@ -433,10 +438,11 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ setHasUnsavedChanges(false); } catch (error) { console.error('❌ Manual sync failed:', error); + errorNotify(); } finally { setIsSyncing(false); } - }, [dataService, stableSaveCurrentDay, projects, categories, archivedDays, todoItems]); + }, [dataService, stableSaveCurrentDay, projects, categories, archivedDays, todoItems, errorNotify]); // Load current day data (for periodic sync) const loadCurrentDay = useCallback(async () => { @@ -480,27 +486,23 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [dataService, isDayStarted, tasks, currentTask, dayStartTime]); - // iOS/Capacitor apps don't reliably fire beforeunload when backgrounded or killed. - // visibilitychange fires when the app is suspended, giving us a last chance to - // write a synchronous backup before JavaScript execution is frozen. - useEffect(() => { - const handleVisibilityChange = () => { - if (document.visibilityState !== "hidden") return; - if (!isDayStarted && tasks.length === 0) return; - try { - localStorage.setItem( - STORAGE_KEYS.CURRENT_DAY, - JSON.stringify({ isDayStarted, dayStartTime, tasks, currentTask, _v: SCHEMA_VERSION }) - ); - } catch { - // best effort - } - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + // On iOS/Capacitor, useAppLifecycle fires at the Swift layer (appStateChange) + // before WKWebView is frozen — more reliable than beforeunload or visibilitychange. + // On web, it falls back to visibilitychange automatically. + const handleBackground = useCallback(() => { + if (!isDayStarted && tasks.length === 0) return; + try { + localStorage.setItem( + STORAGE_KEYS.CURRENT_DAY, + JSON.stringify({ isDayStarted, dayStartTime, tasks, currentTask, _v: SCHEMA_VERSION }) + ); + } catch { + // best effort + } }, [isDayStarted, dayStartTime, tasks, currentTask]); + useAppLifecycle(handleBackground); + // Sync to backend when coming back online useEffect(() => { const handleOnline = () => { @@ -610,6 +612,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ setTasks(updatedTasks); setCurrentTask(newTask); setHasUnsavedChanges(true); + successNotify(); // Save with freshly computed state to avoid reading from stale latestStateRef if (dataService) { dataService.saveCurrentDay({ isDayStarted, dayStartTime, currentTask: newTask, tasks: updatedTasks }) @@ -714,6 +717,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ } setHasUnsavedChanges(false); + successNotify(); // Show success notification to user toast({ diff --git a/src/hooks/useAppLifecycle.ts b/src/hooks/useAppLifecycle.ts new file mode 100644 index 0000000..a0e85d1 --- /dev/null +++ b/src/hooks/useAppLifecycle.ts @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import { Capacitor } from "@capacitor/core"; + +/** + * Calls onBackground when the app is suspended (native) or hidden (web). + * On iOS/Capacitor, uses @capacitor/app's appStateChange which fires at the + * Swift layer before WKWebView freezes — more reliable than visibilitychange. + * Falls back to visibilitychange on web. + */ +export function useAppLifecycle(onBackground: () => void) { + useEffect(() => { + if (Capacitor.isNativePlatform()) { + // Dynamic import avoids a hard dependency when running as a PWA + // (the plugin is present but the runtime only activates on native) + import("@capacitor/app").then(({ App }) => { + const listenerPromise = App.addListener("appStateChange", ({ isActive }) => { + if (!isActive) { + onBackground(); + } + }); + return () => { + listenerPromise.then((handle) => handle.remove()); + }; + }); + } else { + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + onBackground(); + } + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + } + // onBackground is intentionally excluded — callers should memoize with useCallback + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/src/hooks/useHaptics.ts b/src/hooks/useHaptics.ts new file mode 100644 index 0000000..d604518 --- /dev/null +++ b/src/hooks/useHaptics.ts @@ -0,0 +1,51 @@ +import { Capacitor } from "@capacitor/core"; + +let hapticsModule: typeof import("@capacitor/haptics") | null = null; + +async function getHaptics() { + if (!Capacitor.isNativePlatform()) return null; + if (!hapticsModule) { + hapticsModule = await import("@capacitor/haptics"); + } + return hapticsModule; +} + +export function useHaptics() { + const lightImpact = () => { + getHaptics().then((h) => { + if (h) h.Haptics.impact({ style: h.ImpactStyle.Light }); + }); + }; + + const mediumImpact = () => { + getHaptics().then((h) => { + if (h) h.Haptics.impact({ style: h.ImpactStyle.Medium }); + }); + }; + + const heavyImpact = () => { + getHaptics().then((h) => { + if (h) h.Haptics.impact({ style: h.ImpactStyle.Heavy }); + }); + }; + + const successNotify = () => { + getHaptics().then((h) => { + if (h) h.Haptics.notification({ type: h.NotificationType.Success }); + }); + }; + + const errorNotify = () => { + getHaptics().then((h) => { + if (h) h.Haptics.notification({ type: h.NotificationType.Error }); + }); + }; + + const warnNotify = () => { + getHaptics().then((h) => { + if (h) h.Haptics.notification({ type: h.NotificationType.Warning }); + }); + }; + + return { lightImpact, mediumImpact, heavyImpact, successNotify, errorNotify, warnNotify }; +} diff --git a/src/hooks/useKeyboardHeight.ts b/src/hooks/useKeyboardHeight.ts new file mode 100644 index 0000000..6f8e0a4 --- /dev/null +++ b/src/hooks/useKeyboardHeight.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from "react"; +import { Capacitor } from "@capacitor/core"; + +/** + * Returns the current on-screen keyboard height in px (0 when hidden). + * Uses @capacitor/keyboard events on native; always returns 0 on web. + */ +export function useKeyboardHeight(): number { + const [height, setHeight] = useState(0); + + useEffect(() => { + if (!Capacitor.isNativePlatform()) return; + + let showHandle: { remove: () => void } | null = null; + let hideHandle: { remove: () => void } | null = null; + + import("@capacitor/keyboard").then(({ Keyboard }) => { + Keyboard.addListener("keyboardWillShow", (info) => { + setHeight(info.keyboardHeight); + }).then((handle) => { showHandle = handle; }); + + Keyboard.addListener("keyboardWillHide", () => { + setHeight(0); + }).then((handle) => { hideHandle = handle; }); + }); + + return () => { + showHandle?.remove(); + hideHandle?.remove(); + }; + }, []); + + return height; +} diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts new file mode 100644 index 0000000..59f4992 --- /dev/null +++ b/src/hooks/useLongPress.ts @@ -0,0 +1,33 @@ +import { useRef, useCallback } from "react"; + +/** + * Returns pointer event handlers that fire `callback` after the pointer has + * been held down for `delay` ms without moving. Used to programmatically + * open a context menu on touch devices (where right-click is unavailable). + */ +export function useLongPress(callback: () => void, delay = 500) { + const timerRef = useRef | null>(null); + const cancelledRef = useRef(false); + + const start = useCallback(() => { + cancelledRef.current = false; + timerRef.current = setTimeout(() => { + if (!cancelledRef.current) callback(); + }, delay); + }, [callback, delay]); + + const cancel = useCallback(() => { + cancelledRef.current = true; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + return { + onPointerDown: start, + onPointerUp: cancel, + onPointerLeave: cancel, + onPointerCancel: cancel, + }; +} diff --git a/src/hooks/useStatusBar.ts b/src/hooks/useStatusBar.ts new file mode 100644 index 0000000..a368716 --- /dev/null +++ b/src/hooks/useStatusBar.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { Capacitor } from "@capacitor/core"; + +/** + * Syncs the iOS status bar text colour with the app's light/dark theme. + * Style.Dark = white text (for dark backgrounds). + * Style.Light = black text (for light backgrounds). + * No-op on web. + */ +export function useStatusBar(isDark: boolean) { + useEffect(() => { + if (!Capacitor.isNativePlatform()) return; + + import("@capacitor/status-bar").then(({ StatusBar, Style }) => { + StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }); + }); + }, [isDark]); +} diff --git a/src/main.tsx b/src/main.tsx index 495d50f..330557b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,10 @@ import App from './App.tsx'; import '@radix-ui/themes/styles.css'; import './index.css'; +if (import.meta.env.VITE_IOS_BUILD === "true") { + document.body.classList.add("ios-build"); +} + createRoot(document.getElementById('root')!).render( diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 376a442..776bb7e 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -87,7 +87,7 @@ const TimeTrackerContent = () => { } return ( - + }>
{ onStartDay={handleStartDayWithDateTime} /> - {/* Dashboard header */} -
-

- - Dashboard -

-
- {/* Stats (always visible) */} {!isDayStarted && (