Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Framer Motion animations on app-layer components — task list add/delete (`AnimatePresence` + `layout` reflow), active-task scale/ring transition, PWA update/install prompt slide in/out, and a `layoutId`-based sliding active indicator on the mobile nav
— `src/pages/Index.tsx`, `src/components/TaskItem.tsx`, `src/components/PwaUpdatePrompt.tsx`, `src/components/InstallPrompt.tsx`, `src/components/MobileNav.tsx`

- Planned task time tracking — `PlannedTask` now accumulates total time worked via a `timeEntries: PlannedTaskTimeEntry[]` array and a denormalized `timeSpent` (milliseconds) field. Each time a planned task is pulled to the active day (or resumed via "Add to Active Day"), a new `PlannedTaskTimeEntry` is appended with the linked timed-task id and date; its `duration` is filled in when the timed task ends
— `src/contexts/TimeTrackingContext.tsx`, `src/services/supabaseService.ts`

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.17.0",
"motion": "^12.40.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
Expand Down
60 changes: 60 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 48 additions & 39 deletions src/components/InstallPrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { useState, useEffect, memo } from 'react';
import { Button } from '@/components/ui/button';
import { useState, useEffect, memo } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@/components/ui/card';
import { Download, X } from 'lucide-react';
} from "@/components/ui/card";
import { Download, X } from "lucide-react";

interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}

export const InstallPrompt = memo(function InstallPrompt() {
Expand All @@ -22,14 +23,14 @@ export const InstallPrompt = memo(function InstallPrompt() {

useEffect(() => {
// Check if app is already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
if (window.matchMedia("(display-mode: standalone)").matches) {
setIsInstalled(true);
return;
}

// Check if user has previously dismissed the prompt
try {
const dismissed = localStorage.getItem('pwa-install-dismissed');
const dismissed = localStorage.getItem("pwa-install-dismissed");
if (dismissed) {
const dismissedTime = parseInt(dismissed, 10);
const daysSinceDismissed =
Expand Down Expand Up @@ -61,12 +62,12 @@ export const InstallPrompt = memo(function InstallPrompt() {
setDeferredPrompt(null);
};

window.addEventListener('beforeinstallprompt', handler);
window.addEventListener('appinstalled', installedHandler);
window.addEventListener("beforeinstallprompt", handler);
window.addEventListener("appinstalled", installedHandler);

return () => {
window.removeEventListener('beforeinstallprompt', handler);
window.removeEventListener('appinstalled', installedHandler);
window.removeEventListener("beforeinstallprompt", handler);
window.removeEventListener("appinstalled", installedHandler);
};
}, []);

Expand All @@ -77,51 +78,59 @@ export const InstallPrompt = memo(function InstallPrompt() {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;

if (outcome === 'accepted') {
if (outcome === "accepted") {
setIsInstalled(true);
}

setDeferredPrompt(null);
setShowPrompt(false);
} catch (error) {
console.error('Error showing install prompt:', error);
console.error("Error showing install prompt:", error);
}
};

const handleDismiss = () => {
setShowPrompt(false);
try {
localStorage.setItem('pwa-install-dismissed', Date.now().toString());
localStorage.setItem("pwa-install-dismissed", Date.now().toString());
} catch {
// localStorage unavailable; dismiss state not persisted
}
};

if (!showPrompt || !deferredPrompt || isInstalled) {
return null;
}

return (
<Card className="fixed bottom-4 right-4 w-80 shadow-lg z-50 animate-in slide-in-from-bottom-4 print:hidden">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-1 rounded-full hover:bg-gray-100 transition-colors"
aria-label="Dismiss install prompt"
>
<X className="h-4 w-4" />
</button>
<CardHeader>
<CardTitle className="text-lg">Install Timetraked</CardTitle>
<CardDescription>
Install the app for quick access and offline functionality
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={handleInstall} className="w-full">
<Download className="mr-2 h-4 w-4" />
Install App
</Button>
</CardContent>
</Card>
<AnimatePresence>
{showPrompt && deferredPrompt && !isInstalled && (
<motion.div
initial={{ y: 24, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 24, opacity: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="fixed bottom-4 right-4 z-50 print:hidden"
>
<Card className="w-80 shadow-lg">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-1 rounded-full hover:bg-gray-100 transition-colors"
aria-label="Dismiss install prompt"
>
<X className="h-4 w-4" />
</button>
<CardHeader>
<CardTitle className="text-lg">Install Timetraked</CardTitle>
<CardDescription>
Install the app for quick access and offline functionality
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={handleInstall} className="w-full">
<Download className="mr-2 h-4 w-4" />
Install App
</Button>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
);
});
60 changes: 34 additions & 26 deletions src/components/MobileNav.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { memo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { memo } from "react";
import { Link, useLocation } from "react-router-dom";
import { motion } from "motion/react";
import {
Home,
Archive,
Settings,
PaperclipIcon,
ClipboardList
} from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
} from "lucide-react";
import { useAuth } from "@/hooks/useAuth";

export const MobileNav = memo(function MobileNav() {
const location = useLocation();
Expand All @@ -19,65 +20,72 @@ export const MobileNav = memo(function MobileNav() {

const navItems = [
{
path: '/',
path: "/",
icon: Home,
label: 'Home'
label: "Home"
},
{
path: '/tasks',
path: "/tasks",
icon: ClipboardList,
label: 'Tasks'
label: "Tasks"
},
...(isAuthenticated
? [
{
path: '/report',
path: "/report",
icon: PaperclipIcon,
label: 'Report'
label: "Report"
}
]
: []),
{
path: '/archive',
path: "/archive",
icon: Archive,
label: 'Archive'
label: "Archive"
},
{
path: '/settings',
path: "/settings",
icon: Settings,
label: 'Settings'
label: "Settings"
}
];

const gridClass =
navItems.length <= 3
? 'grid-cols-3'
? "grid-cols-3"
: navItems.length === 4
? 'grid-cols-4'
: 'grid-cols-5';
? "grid-cols-4"
: "grid-cols-5";

return (
<nav
className="fixed bottom-0 left-0 right-0 mb-2 mx-2 rounded-full border border-border bg-card/80 shadow-lg md:hidden z-40 print:hidden"
style={{
padding: 'max(env(safe-area-inset-bottom, 8px), 8px)'
padding: "max(env(safe-area-inset-bottom, 8px), 8px)"
}}
>
<div className={`grid ${gridClass} h-12`}>
{navItems.map(({ path, icon: Icon, label }) => (
<Link
key={path}
to={path}
className={`flex flex-col items-center justify-center space-y-1 transition-colors touch-manipulation ${
isActive(path)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
className="relative flex flex-col items-center justify-center space-y-1 transition-colors touch-manipulation text-muted-foreground hover:text-foreground"
aria-label={label}
aria-current={isActive(path) ? 'page' : undefined}
aria-current={isActive(path) ? "page" : undefined}
>
<Icon className="h-5 w-5" />
<span className="text-xs font-medium">{label}</span>
{isActive(path) && (
<motion.div
layoutId="mobile-nav-active"
className="absolute inset-0 rounded-full bg-accent"
transition={{ duration: 0.2, ease: "easeOut" }}
/>
)}
<span className={`relative z-10 ${isActive(path) ? "text-primary" : ""}`}>
<Icon className="h-5 w-5" />
</span>
<span className={`relative z-10 text-xs font-medium ${isActive(path) ? "text-primary" : ""}`}>
{label}
</span>
</Link>
))}
</div>
Expand Down
Loading
Loading