feat: upgrade to React 19, add keyboard shortcuts, implement Zustand global state, fix environment variables, and improve UI components

- Upgrade React to v19.1.1 and update all related packages
- Add comprehensive keyboard shortcuts (Space, R, S, Ctrl+D) with visual indicators
- Implement Zustand global state management for shortcuts and app state
- Fix .env file loading with dotenv package and proper Vite configuration
- Add text wrapping to all card components to prevent overflow
- Improve theme toggle visibility and styling in sidebar
- Update button layouts to use flex-direction: row
- Add hover effects and consistent styling across components
- Fix infinite loop issues in keyboard shortcuts hook
- Update Vite config to properly handle .env files and source directories
- Add proper TypeScript configuration for React 19 JSX transform
This commit is contained in:
Carlos Gutierrez
2025-09-01 12:36:27 -04:00
parent 80595e7002
commit 3b577288da
30 changed files with 3324 additions and 2510 deletions

145
.gitignore vendored
View File

@@ -4,3 +4,148 @@ dist
server/public server/public
vite.config.ts.* vite.config.ts.*
*.tar.gz *.tar.gz
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node

39
.replit
View File

@@ -1,39 +0,0 @@
modules = ["nodejs-20", "web", "postgresql-16"]
run = "npm run dev"
hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"]
[nix]
channel = "stable-24_05"
[deployment]
deploymentTarget = "autoscale"
build = ["npm", "run", "build"]
run = ["npm", "run", "start"]
[[ports]]
localPort = 5000
externalPort = 80
[env]
PORT = "5000"
[workflows]
runButton = "Project"
[[workflows.workflow]]
name = "Project"
mode = "parallel"
author = "agent"
[[workflows.workflow.tasks]]
task = "workflow.run"
args = "Start application"
[[workflows.workflow]]
name = "Start application"
author = "agent"
[[workflows.workflow.tasks]]
task = "shell.exec"
args = "npm run dev"
waitForPort = 5000

212
README.md Normal file
View File

@@ -0,0 +1,212 @@
# Pomodoro Timer
A modern, feature-rich Pomodoro timer application built with React, TypeScript, and Express. This application helps users stay focused and productive using the Pomodoro Technique, with beautiful UI components following atomic design principles.
## Features
- **Pomodoro Timer**: Focus sessions (25 min) and break sessions (5 min) with customizable durations
- **Session Tracking**: Record and analyze your productivity sessions
- **Progress Visualization**: Beautiful progress rings and charts to track your focus time
- **Sound Notifications**: Audio alerts when sessions complete
- **Responsive Design**: Works seamlessly on desktop and mobile devices
- **Dark/Light Theme**: Toggle between themes for comfortable viewing
- **Session History**: View detailed analytics and progress over time
- **Settings Customization**: Adjust timer durations, sounds, and preferences
- **Offline Support**: Works even when disconnected from the internet
## Architecture
This project follows **Atomic Design** principles and is built with a modern tech stack:
### Frontend (Client)
- **React 18** with TypeScript
- **Atomic Design Components**:
- **Atoms**: Button, ProgressRing, SoundToggle
- **Molecules**: DurationSelector, SuggestionChips, TimerControls
- **Organisms**: HistoryChart, PomodoroTimer, Sidebar
- **Templates**: MainLayout
- **State Management**: Zustand for local state
- **Styling**: Tailwind CSS with shadcn/ui components
- **Routing**: Wouter for lightweight routing
- **Data Fetching**: TanStack Query (React Query)
### Backend (Server)
- **Express.js** with TypeScript
- **Database**: PostgreSQL with Drizzle ORM
- **Authentication**: Passport.js with local strategy
- **Session Management**: Express sessions with memory store
- **Real-time**: WebSocket support for live updates
### Shared
- **Schema Validation**: Zod schemas for type safety
- **Database Schema**: Shared between client and server
## Getting Started
### Prerequisites
- Node.js 18+
- PostgreSQL database
- npm or yarn
### Installation
1. **Clone the repository**
```bash
git clone https://github.com/CarGDev/pomodoro
cd pomodoro
```
2. **Install dependencies**
```bash
npm install
```
3. **Set up environment variables**
Create a `.env` file in the root directory:
```env
DATABASE_URL=postgresql://username:password@localhost:5432/pomodoro
SESSION_SECRET=your-session-secret
PORT=5000
```
4. **Set up the database**
```bash
npm run db:push
```
5. **Start the development server**
```bash
npm run dev
```
The application will be available at `http://localhost:5000`
### Build for Production
```bash
npm run build
npm start
```
## Project Structure
```
pomodoro/
├── client/ # Frontend React application
│ ├── src/
│ │ ├── components/ # Atomic design components
│ │ │ ├── atoms/ # Basic building blocks
│ │ │ ├── molecules/ # Simple component combinations
│ │ │ ├── organisms/ # Complex UI sections
│ │ │ ├── templates/ # Page layouts
│ │ │ └── ui/ # shadcn/ui components
│ │ ├── hooks/ # Custom React hooks
│ │ ├── lib/ # Utilities and configurations
│ │ ├── pages/ # Application pages
│ │ └── main.tsx # Application entry point
├── server/ # Backend Express server
│ ├── index.ts # Server entry point
│ ├── routes.ts # API route definitions
│ └── storage.ts # Database operations
├── shared/ # Shared code between client/server
│ └── schema.ts # Database schemas and types
├── drizzle.config.ts # Database configuration
└── package.json # Project dependencies
```
## Key Components
### PomodoroTimer
The main timer component that manages focus and break sessions, tracks progress, and handles session completion.
### ProgressRing
A beautiful circular progress indicator showing time remaining in the current session.
### HistoryChart
Visualizes productivity data over time using Recharts, helping users track their progress.
### DurationSelector
Allows users to customize focus and break session durations according to their preferences.
## Available Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run start` - Start production server
- `npm run check` - TypeScript type checking
- `npm run db:push` - Push database schema changes
## UI Components
This project uses **shadcn/ui** components built on top of Radix UI primitives, providing:
- Accessible components following WAI-ARIA guidelines
- Consistent design system
- Dark/light theme support
- Responsive design patterns
- Beautiful animations with Framer Motion
## Data Flow
1. **Timer State**: Managed locally with Zustand for immediate responsiveness
2. **Session Recording**: Sessions are stored locally first, then synced to the server
3. **Analytics**: Historical data is fetched from the server and cached with React Query
4. **Real-time Updates**: WebSocket connections provide live updates when available
## API Endpoints
- `POST /api/sessions` - Create new session
- `GET /api/sessions` - Retrieve session history
- `GET /api/stats/summary` - Get productivity statistics
- `GET /api/suggestions` - Get productivity suggestions
## Security Features
- Session-based authentication
- Input validation with Zod schemas
- SQL injection protection with Drizzle ORM
- Secure session management
## Development
### Code Style
- TypeScript strict mode enabled
- ESLint and Prettier for code formatting
- Atomic design principles for component organization
- Consistent naming conventions
### Testing
- Component testing with React Testing Library
- API testing with supertest
- End-to-end testing with Playwright
## Mobile Support
- Responsive design that works on all screen sizes
- Touch-friendly interface elements
- Progressive Web App (PWA) capabilities
- Offline functionality with service workers
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Acknowledgments
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
- [Drizzle ORM](https://orm.drizzle.team/) for type-safe database operations
- [Tailwind CSS](https://tailwindcss.com/) for utility-first styling
- [Framer Motion](https://www.framer.com/motion/) for smooth animations
---
**Happy focusing!**

View File

@@ -1,204 +0,0 @@
got it — heres a clean, copy-paste **prompt** you can drop into your favorite codegen/agent to build the app exactly as you described. after the prompt, I added a compact API contract and folder blueprint so the output stays consistent.
---
# Prompt: “Pomodorian Study Sessions Timer (sound + suggestions + history, no login)”
**Goal:** Build a full-stack “Pomodorian” study timer that plays a sound at the end of sessions, lets users configure times, receives smart time suggestions based on past usage, and records a local/anonymous history. No authentication required.
## Tech & Architecture
* **Client:** React + TypeScript, **Vite**, **Ant Design (antd)**, **Framer Motion**, **Atomic Design** (atoms/molecules/organisms/templates/pages).
* **Server:** Node.js + TypeScript, **Express**, **MVC pattern** (Models / Views / Controllers; Views are JSON only).
* **State:** Lightweight (e.g., Zustand or Context) on client; keep it simple.
* **Storage:**
* Client: localStorage/IndexedDB for quick offline history.
* Server: simple persistence (SQLite or file-based) for anonymized analytics & suggestions. Identify users via a generated anonymous `deviceId` cookie (no login).
* **Sound:** Play an audible signal on session end and break start; default and user-selectable options.
## Core Features
1. **Pomodoro Timer**
* Presets: 25/5 (focus/break), 50/10, plus **custom** durations.
* Visual progress ring, time left, start/pause/reset, skip break.
* Optional long break every N focus sessions.
* **Sound** on phase change (respect browser autoplay policies; request interaction first). Volume and mute toggle.
2. **Time Suggestions**
* Show 3 dynamic suggestions (e.g., “25 min focus”, “40 min deep work”, “15 min power sprint”) based on recent completion rate, preferred durations, and time-of-day success.
* Explainability note (“Based on your last 7 sessions…”).
3. **History (No Login)**
* Log each session locally: start/end timestamps, intended/actual duration, focus/break, completion status, interruptions count.
* Simple charts: sessions per day, completion rate, average focus duration.
* Export CSV/JSON.
4. **No-Login, Anonymous**
* On first load, generate `deviceId` (UUID) and store in cookie/localStorage.
* Use `deviceId` on server endpoints for suggestions & aggregate trends (no PII).
5. **Polish**
* Dark/light theme toggle.
* Keyboard shortcuts: Space (start/pause), R (reset), S (skip break).
* PWA basics (installable + offline for timer & history).
## Client Requirements
* **Atomic Design structure** with Ant Design components wrapped as atoms/molecules.
* **Pages:** Home (Timer), History/Insights, Settings.
* **Animations:** Framer Motion for page transitions and subtle UI feedback (button taps, card entrance).
* **Accessibility:** Focus states, ARIA for timer, sound toggle explained.
* **Persistence:** Local history mirrors to server when online (best-effort).
* **Error states:** Graceful toasts via antd.
## Server (MVC) Requirements
* **Routes (JSON only):**
* `POST /api/sessions` — record session result `{ deviceId, type, intendedMinutes, actualSeconds, startedAt, endedAt, completed, interruptions }`.
* `GET /api/suggestions?deviceId=...` — return up to 3 durations with reasons.
* `GET /api/stats/summary?deviceId=...` — compact aggregates for charts (optional).
* **Controllers:** validation, error handling, 2xx/4xx/5xx responses with clear messages.
* **Models:** Session model; Suggestions service (uses simple heuristics: moving average of completed sessions, time-of-day buckets).
* **Persistence:** SQLite with a minimal schema (`sessions` table, `device_profiles` optional).
* **No auth**; CORS enabled for the Vite client origin only.
* **Tests:** minimal unit tests for suggestions logic.
## Acceptance Criteria
* Timer plays a sound reliably on phase changes after first user interaction.
* User can pick from **suggested durations** and see a one-line reason.
* History shows today + 7/30-day trends; export works.
* Works entirely without login; consistent anonymous `deviceId`.
* Clean, responsive UI using Ant Design; animated but subtle.
* Code is organized: atomic design on client; MVC on server; TypeScript everywhere.
* One-command dev start for client and server; `.env.example` provided.
## Deliverables
* **Client folder** (`vite` + `antd` + `framer-motion` + atomic design), **Server folder** (Node/TS/Express MVC).
* Sound assets (short beep + gentle chime).
* README with setup, scripts, and architectural notes.
---
## API Contract (compact)
**POST** `/api/sessions`
Request:
```json
{
"deviceId": "uuid",
"type": "focus|break",
"intendedMinutes": 25,
"actualSeconds": 1490,
"startedAt": "2025-08-31T14:05:00Z",
"endedAt": "2025-08-31T14:30:50Z",
"completed": true,
"interruptions": 1
}
```
Response `201`:
```json
{ "ok": true, "id": "session_id" }
```
**GET** `/api/suggestions?deviceId=uuid`
Response `200`:
```json
{
"suggestions": [
{ "minutes": 25, "reason": "Your 7-day completion rate is 82% at 25m." },
{ "minutes": 40, "reason": "Afternoons: best focus avg 3742m." },
{ "minutes": 15, "reason": "High interruption mornings benefit from short sprints." }
]
}
```
**GET** `/api/stats/summary?deviceId=uuid`
Response `200`:
```json
{
"last7d": { "sessions": 18, "completionRate": 0.76, "avgFocusMin": 28 },
"last30d": { "sessions": 64, "completionRate": 0.71, "avgFocusMin": 26 }
}
```
---
## Folder Blueprint
```
pomodorian/
client/ # Vite + React + TS
src/
assets/sounds/ # chime.mp3, beep.mp3
design/ # theme tokens, colors
state/ # store (Zustand/Context)
utils/ # time, audio, id
api/ # fetchers for /api/*
components/
atoms/
Button.tsx
Toggle.tsx
ProgressRing.tsx
SoundPicker.tsx
molecules/
TimerControls.tsx
DurationSelector.tsx
SuggestionChips.tsx
organisms/
PomodoroTimer.tsx
HistoryChart.tsx
ExportPanel.tsx
templates/
MainLayout.tsx
pages/
Home.tsx
History.tsx
Settings.tsx
index.html
main.tsx
vite.config.ts
tsconfig.json
server/ # Node + TS + Express (MVC)
src/
app.ts
routes/
index.ts
sessions.routes.ts
suggestions.routes.ts
controllers/
sessions.controller.ts
suggestions.controller.ts
models/
session.model.ts
services/
suggestions.service.ts
stats.service.ts
db/
prisma.schema.sql or knex migrations
middleware/
cors.ts
error.ts
utils/
validate.ts
package.json
tsconfig.json
README.md
```
---
If you want, I can turn this into a starter repo (with Vite, antd, motion, and a minimal Express MVC) and drop in a couple of example components & endpoints.

View File

@@ -10,7 +10,5 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
</body> </body>
</html> </html>

View File

@@ -7,12 +7,20 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { ThemeProvider } from "@/lib/theme"; import { ThemeProvider } from "@/lib/theme";
import { useStore } from "@/lib/store"; import { useStore } from "@/lib/store";
import { getOrCreateDeviceId } from "@/lib/device"; import { getOrCreateDeviceId } from "@/lib/device";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { ShortcutIndicator } from "@/components/atoms/ShortcutIndicator";
import Home from "@/pages/Home"; import Home from "@/pages/Home";
import History from "@/pages/History"; import History from "@/pages/History";
import Settings from "@/pages/Settings"; import Settings from "@/pages/Settings";
import NotFound from "@/pages/not-found"; import NotFound from "@/pages/not-found";
// Separate component for keyboard shortcuts to prevent infinite loops
function KeyboardShortcutsProvider() {
useKeyboardShortcuts();
return null;
}
function Router() { function Router() {
return ( return (
<Switch> <Switch>
@@ -41,7 +49,9 @@ function App() {
<ThemeProvider defaultTheme="light" storageKey="pomodorian-theme"> <ThemeProvider defaultTheme="light" storageKey="pomodorian-theme">
<TooltipProvider> <TooltipProvider>
<Toaster /> <Toaster />
<KeyboardShortcutsProvider />
<Router /> <Router />
<ShortcutIndicator />
</TooltipProvider> </TooltipProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>

View File

@@ -0,0 +1,22 @@
import { useStore } from '@/lib/store';
import { Keyboard } from 'lucide-react';
interface ShortcutIndicatorProps {
className?: string;
}
export function ShortcutIndicator({ className = "" }: ShortcutIndicatorProps) {
// Get shortcuts state from Zustand store
const { lastShortcut, isVisible } = useStore((state) => state.shortcuts);
if (!isVisible || !lastShortcut) return null;
return (
<div className={`fixed bottom-4 right-4 bg-primary text-primary-foreground px-3 py-2 rounded-lg shadow-lg flex items-center space-x-2 z-50 animate-in slide-in-from-bottom-2 duration-300 ${className}`}>
<Keyboard className="w-4 h-4" />
<span className="text-sm font-medium">
{lastShortcut}
</span>
</div>
);
}

View File

@@ -59,24 +59,24 @@ export function DurationSelector({
<Button <Button
key={preset.name} key={preset.name}
variant="outline" variant="outline"
className={`w-full p-3 h-auto text-left group ${ className={`w-full p-3 h-auto text-left group min-h-[80px] ${
selectedPreset === preset.name ? 'border-primary bg-primary/5' : '' selectedPreset === preset.name ? 'border-primary bg-primary/5' : ''
}`} }`}
onClick={() => handlePresetSelect(preset)} onClick={() => handlePresetSelect(preset)}
data-testid={`button-preset-${preset.name.toLowerCase()}`} data-testid={`button-preset-${preset.name.toLowerCase()}`}
> >
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full h-full">
<div> <div className="flex-1 min-w-0">
<div className={`font-medium ${ <div className={`font-medium break-words ${
selectedPreset === preset.name ? 'text-primary' : 'group-hover:text-primary' selectedPreset === preset.name ? 'text-primary' : 'group-hover:text-primary'
} transition-colors`}> } transition-colors`}>
{preset.name} {preset.name}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground break-words leading-relaxed">
{preset.description} {preset.description}
</div> </div>
</div> </div>
<div className={`w-4 h-4 rounded-full border-2 ${ <div className={`w-4 h-4 rounded-full border-2 flex-shrink-0 ml-3 ${
selectedPreset === preset.name selectedPreset === preset.name
? 'border-primary bg-primary' ? 'border-primary bg-primary'
: 'border-muted-foreground' : 'border-muted-foreground'

View File

@@ -65,18 +65,18 @@ export function SuggestionChips({ onSuggestionSelect, className = "" }: Suggesti
<Button <Button
key={index} key={index}
variant="outline" variant="outline"
className="p-4 h-auto text-left group hover:border-primary hover:bg-primary/5 transition-all" className="p-4 h-auto text-left group hover:border-primary hover:bg-primary/5 transition-all min-h-[120px]"
onClick={() => onSuggestionSelect(suggestion.minutes)} onClick={() => onSuggestionSelect(suggestion.minutes)}
data-testid={`button-suggestion-${suggestion.minutes}`} data-testid={`button-suggestion-${suggestion.minutes}`}
> >
<div className="w-full"> <div className="w-full h-full flex flex-col justify-between">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="font-medium group-hover:text-primary transition-colors"> <span className="font-medium group-hover:text-primary transition-colors break-words">
{suggestion.minutes} min {suggestion.minutes >= 30 ? 'Deep Work' : suggestion.minutes <= 15 ? 'Sprint' : 'Focus'} {suggestion.minutes} min {suggestion.minutes >= 30 ? 'Deep Work' : suggestion.minutes <= 15 ? 'Sprint' : 'Focus'}
</span> </span>
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" /> <ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors flex-shrink-0 ml-2" />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground break-words leading-relaxed">
{suggestion.reason} {suggestion.reason}
</p> </p>
</div> </div>

View File

@@ -10,7 +10,11 @@ interface SidebarProps {
onNavigate?: () => void; onNavigate?: () => void;
} }
export function Sidebar({ className = "", isMobile = false, onNavigate }: SidebarProps) { export function Sidebar({
className = "",
isMobile = false,
onNavigate,
}: SidebarProps) {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [location] = useLocation(); const [location] = useLocation();
const localSessions = useStore((state) => state.localSessions); const localSessions = useStore((state) => state.localSessions);
@@ -19,22 +23,33 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const todaySessions = localSessions.filter(session => { const todaySessions = localSessions.filter((session) => {
const sessionDate = new Date(session.startedAt); const sessionDate = new Date(session.startedAt);
sessionDate.setHours(0, 0, 0, 0); sessionDate.setHours(0, 0, 0, 0);
return sessionDate.getTime() === today.getTime(); return sessionDate.getTime() === today.getTime();
}); });
const todayFocusSessions = todaySessions.filter(s => s.type === 'focus'); const todayFocusSessions = todaySessions.filter((s) => s.type === "focus");
const completedToday = todayFocusSessions.filter(s => s.completed); const completedToday = todayFocusSessions.filter((s) => s.completed);
const completionRate = todayFocusSessions.length > 0 const completionRate =
? Math.round((completedToday.length / todayFocusSessions.length) * 100) todayFocusSessions.length > 0
: 0; ? Math.round((completedToday.length / todayFocusSessions.length) * 100)
: 0;
const navItems = [ const navItems = [
{ path: "/", icon: Clock, label: "Timer", testId: "nav-timer" }, { path: "/", icon: Clock, label: "Timer", testId: "nav-timer" },
{ path: "/history", icon: BarChart3, label: "History", testId: "nav-history" }, {
{ path: "/settings", icon: Settings, label: "Settings", testId: "nav-settings" }, path: "/history",
icon: BarChart3,
label: "History",
testId: "nav-history",
},
{
path: "/settings",
icon: Settings,
label: "Settings",
testId: "nav-settings",
},
]; ];
const isActive = (path: string) => { const isActive = (path: string) => {
@@ -44,9 +59,11 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
}; };
return ( return (
<aside className={`w-64 bg-card border-r border-border flex-shrink-0 sidebar-transition ${ <aside
isMobile ? 'sidebar-mobile' : '' className={`w-64 bg-card border-r border-border flex-shrink-0 sidebar-transition ${
} ${className}`}> isMobile ? "sidebar-mobile" : ""
} ${className}`}
>
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Logo/Brand */} {/* Logo/Brand */}
<div className="p-6 border-b border-border"> <div className="p-6 border-b border-border">
@@ -70,8 +87,8 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
variant={active ? "default" : "ghost"} variant={active ? "default" : "ghost"}
className={`w-full justify-start space-x-3 ${ className={`w-full justify-start space-x-3 ${
active active
? 'bg-primary text-primary-foreground' ? "bg-primary text-primary-foreground"
: 'text-muted-foreground hover:bg-muted hover:text-foreground' : "text-muted-foreground hover:bg-muted hover:text-foreground"
}`} }`}
onClick={onNavigate} onClick={onNavigate}
data-testid={item.testId} data-testid={item.testId}
@@ -96,7 +113,10 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-muted-foreground">Completion Rate</span> <span className="text-muted-foreground">Completion Rate</span>
<span className="font-medium text-chart-2" data-testid="stat-today-completion"> <span
className="font-medium text-chart-2"
data-testid="stat-today-completion"
>
{completionRate}% {completionRate}%
</span> </span>
</div> </div>
@@ -105,24 +125,50 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
{/* Theme Toggle */} {/* Theme Toggle */}
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-between" className="w-full justify-between hover:bg-[#f1f5f9] focus-visible:bg-[#f1f5f9] focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2 transition-colors group"
onClick={toggleTheme} onClick={toggleTheme}
data-testid="button-theme-toggle" data-testid="button-theme-toggle"
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
> >
<span className="flex items-center space-x-3"> <span className="flex items-center space-x-3">
{theme === 'dark' ? ( {theme === "dark" ? (
<Sun className="w-5 h-5" /> <Sun className="w-5 h-5 group-hover:text-black transition-colors" />
) : ( ) : (
<Moon className="w-5 h-5" /> <Moon className="w-5 h-5 group-hover:text-black transition-colors" />
)} )}
<span>{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span> <span className="group-hover:text-black transition-colors">
{theme === "dark" ? "Light Mode" : "Dark Mode"}
</span>
</span> </span>
<div className={`w-10 h-6 rounded-full relative transition-colors ${ <div
theme === 'dark' ? 'bg-primary' : 'bg-secondary' className={`w-11 h-6 rounded-full relative transition-all duration-200 ease-in-out ${
}`}> theme === "dark"
<div className={`w-4 h-4 bg-white rounded-full absolute top-1 transition-transform ${ ? "bg-primary shadow-inner"
theme === 'dark' ? 'translate-x-5' : 'translate-x-1' : "bg-zinc-200 border border-zinc-300"
}`} /> }`}
style={
{
"--track-off-bg": theme === "dark" ? "#3a3a3f" : "#e5e7eb",
"--track-on-bg": theme === "dark" ? "#3b82f6" : "#4f46e5",
"--thumb-bg": "#ffffff",
"--thumb-border":
theme === "dark"
? "rgba(255,255,255,0.25)"
: "rgba(0,0,0,0.15)",
"--focus-ring":
theme === "dark"
? "0 0 0 3px rgba(59,130,246,0.35)"
: "0 0 0 3px rgba(79,70,229,0.35)",
} as React.CSSProperties
}
>
<div
className={`w-5 h-5 bg-white rounded-full absolute top-0.5 transition-transform duration-200 ease-in-out shadow-sm border ${
theme === "dark"
? "translate-x-6 border-white/25"
: "translate-x-0.5 border-black/15"
}`}
/>
</div> </div>
</Button> </Button>
</div> </div>

View File

@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden",
className className
)} )}
{...props} {...props}
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6 break-words", className)}
{...props} {...props}
/> />
)) ))
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-2xl font-semibold leading-none tracking-tight break-words",
className className
)} )}
{...props} {...props}
@@ -50,7 +50,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground break-words", className)}
{...props} {...props}
/> />
)) ))
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-6 pt-0 break-words", className)} {...props} />
)) ))
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent"
@@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0 break-words", className)}
{...props} {...props}
/> />
)) ))

View File

@@ -0,0 +1,142 @@
import { useEffect, useCallback, useRef } from 'react';
import { useStore } from '@/lib/store';
import { useTheme } from '@/lib/theme';
import { useToast } from '@/hooks/use-toast';
export function useKeyboardShortcuts() {
console.log('useKeyboardShortcuts hook called'); // Debug log
const { theme, setTheme } = useTheme();
const { toast } = useToast();
// Use refs to avoid dependency issues and prevent re-renders
const shortcutsRef = useRef<any>(null);
const showShortcutRef = useRef<any>(null);
const hideShortcutRef = useRef<any>(null);
const timerRef = useRef<any>(null);
const setTimerRef = useRef<any>(null);
// Get store functions once and store in refs to prevent re-renders
useEffect(() => {
const unsubscribe = useStore.subscribe(
(state) => {
shortcutsRef.current = state.shortcuts;
timerRef.current = state.timer;
}
);
// Get initial values
const state = useStore.getState();
shortcutsRef.current = state.shortcuts;
showShortcutRef.current = state.showShortcut;
hideShortcutRef.current = state.hideShortcut;
timerRef.current = state.timer;
setTimerRef.current = state.setTimer;
return unsubscribe;
}, []); // Empty dependency array - only run once
// Memoize the key handler to prevent recreation
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Check if shortcuts are enabled using ref
if (!shortcutsRef.current?.shortcutsEnabled) return;
// Prevent shortcuts from triggering when typing in input fields
if (event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement) {
return;
}
const currentTimer = timerRef.current;
const currentSetTimer = setTimerRef.current;
if (!currentTimer || !currentSetTimer) return;
// Space: Start/Pause Timer
if (event.code === 'Space' && !event.repeat) {
event.preventDefault();
if (currentTimer.isRunning) {
currentSetTimer({ isRunning: false });
showShortcutRef.current('Space');
toast({
title: "Timer Paused",
description: "Press Space to resume",
duration: 2000,
});
} else if (currentTimer.currentTime > 0) {
currentSetTimer({ isRunning: true });
showShortcutRef.current('Space');
toast({
title: "Timer Started",
description: "Press Space to pause",
duration: 2000,
});
}
}
// R: Reset Timer
if (event.code === 'KeyR' && !event.repeat) {
event.preventDefault();
currentSetTimer({
currentTime: currentTimer.totalTime,
isRunning: false,
startedAt: undefined,
interruptions: 0,
});
showShortcutRef.current('R');
toast({
title: "Timer Reset",
description: "Timer has been reset to original duration",
duration: 2000,
});
}
// S: Skip Break (only when on break)
if (event.code === 'KeyS' && !event.repeat) {
event.preventDefault();
if (currentTimer.phase === 'break' && currentTimer.isRunning) {
currentSetTimer({
isRunning: false,
currentTime: 0,
});
showShortcutRef.current('S');
toast({
title: "Break Skipped",
description: "Break timer has been skipped",
duration: 2000,
});
}
}
// Ctrl + D: Toggle Theme (improved detection)
if (event.ctrlKey && (event.code === 'KeyD' || event.key === 'd' || event.key === 'D') && !event.repeat) {
event.preventDefault();
event.stopPropagation();
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
showShortcutRef.current('Ctrl + D');
toast({
title: `Theme Changed`,
description: `Switched to ${newTheme} mode`,
duration: 2000,
});
}
// Auto-hide shortcut indicator after 2 seconds
setTimeout(() => {
hideShortcutRef.current();
}, 2000);
}, [theme, setTheme, toast]); // Removed store function dependencies
useEffect(() => {
// Add event listener
document.addEventListener('keydown', handleKeyDown);
// Cleanup
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
}

View File

@@ -64,6 +64,29 @@
} }
} }
@layer components {
/* Card text wrapping improvements */
.card-content {
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Button text wrapping improvements */
.button-content {
word-wrap: break-word;
overflow-wrap: break-word;
min-width: 0;
}
/* Table text wrapping improvements */
.table-cell {
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 0;
}
}
.timer-ring { .timer-ring {
transition: stroke-dasharray 0.3s ease-in-out; transition: stroke-dasharray 0.3s ease-in-out;
} }
@@ -98,3 +121,8 @@
transform: translateX(0); transform: translateX(0);
} }
} }
div,
p {
text-wrap: auto !important;
}

View File

@@ -9,15 +9,16 @@ export class AudioManager {
private async initializeAudioContext() { private async initializeAudioContext() {
try { try {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); this.audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
this.gainNode = this.audioContext.createGain(); this.gainNode = this.audioContext.createGain();
this.gainNode.connect(this.audioContext.destination); this.gainNode.connect(this.audioContext.destination);
// Load default sounds // Load default sounds
await this.loadSound('chime', '/src/assets/sounds/chime.mp3'); await this.loadSound("chime", "/src/assets/sounds/chime.mp3");
await this.loadSound('beep', '/src/assets/sounds/beep.mp3'); await this.loadSound("beep", "/src/assets/sounds/beep.mp3");
} catch (error) { } catch (error) {
console.warn('Audio context initialization failed:', error); console.warn("Audio context initialization failed:", error);
} }
} }
@@ -55,12 +56,12 @@ export class AudioManager {
async playSound(soundName: string, volume: number = 0.7) { async playSound(soundName: string, volume: number = 0.7) {
if (!this.audioContext || !this.gainNode) { if (!this.audioContext || !this.gainNode) {
console.warn('Audio context not available'); console.warn("Audio context not available");
return; return;
} }
// Resume audio context if it's suspended (required by browser autoplay policies) // Resume audio context if it's suspended (required by browser autoplay policies)
if (this.audioContext.state === 'suspended') { if (this.audioContext.state === "suspended") {
await this.audioContext.resume(); await this.audioContext.resume();
} }
@@ -78,13 +79,16 @@ export class AudioManager {
source.connect(this.gainNode); source.connect(this.gainNode);
source.start(); source.start();
} catch (error) { } catch (error) {
console.warn('Failed to play sound:', error); console.warn("Failed to play sound:", error);
} }
} }
setVolume(volume: number) { setVolume(volume: number) {
if (this.gainNode) { if (this.gainNode) {
this.gainNode.gain.setValueAtTime(Math.max(0, Math.min(1, volume)), this.audioContext?.currentTime || 0); this.gainNode.gain.setValueAtTime(
Math.max(0, Math.min(1, volume)),
this.audioContext?.currentTime || 0,
);
} }
} }
@@ -93,12 +97,12 @@ export class AudioManager {
if (!this.audioContext) return false; if (!this.audioContext) return false;
try { try {
if (this.audioContext.state === 'suspended') { if (this.audioContext.state === "suspended") {
await this.audioContext.resume(); await this.audioContext.resume();
} }
return true; return true;
} catch (error) { } catch (error) {
console.warn('Failed to request audio permission:', error); console.warn("Failed to request audio permission:", error);
return false; return false;
} }
} }

View File

@@ -1,22 +1,22 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from "uuid";
export function generateDeviceId(): string { export function generateDeviceId(): string {
return uuidv4(); return uuidv4();
} }
export function getOrCreateDeviceId(): string { export function getOrCreateDeviceId(): string {
const stored = localStorage.getItem('pomodorian-device-id'); const stored = localStorage.getItem("pomodorian-device-id");
if (stored) { if (stored) {
return stored; return stored;
} }
const newId = generateDeviceId(); const newId = generateDeviceId();
localStorage.setItem('pomodorian-device-id', newId); localStorage.setItem("pomodorian-device-id", newId);
return newId; return newId;
} }
export function regenerateDeviceId(): string { export function regenerateDeviceId(): string {
const newId = generateDeviceId(); const newId = generateDeviceId();
localStorage.setItem('pomodorian-device-id', newId); localStorage.setItem("pomodorian-device-id", newId);
return newId; return newId;
} }

View File

@@ -1,12 +1,12 @@
import { create } from 'zustand'; import { create } from "zustand";
import { persist } from 'zustand/middleware'; import { persist } from "zustand/middleware";
import type { Session } from '@shared/schema'; import type { Session } from "@shared/schema";
export interface TimerState { export interface TimerState {
isRunning: boolean; isRunning: boolean;
currentTime: number; // seconds currentTime: number; // seconds
totalTime: number; // seconds totalTime: number; // seconds
phase: 'focus' | 'break'; phase: "focus" | "break";
sessionCount: number; sessionCount: number;
interruptions: number; interruptions: number;
startedAt?: Date; startedAt?: Date;
@@ -21,10 +21,16 @@ export interface Settings {
soundEnabled: boolean; soundEnabled: boolean;
soundVolume: number; // 0-1 soundVolume: number; // 0-1
soundType: string; soundType: string;
theme: 'light' | 'dark'; theme: "light" | "dark";
animationsEnabled: boolean; animationsEnabled: boolean;
} }
export interface ShortcutsState {
lastShortcut: string | null;
isVisible: boolean;
shortcutsEnabled: boolean;
}
interface Store { interface Store {
// Timer state // Timer state
timer: TimerState; timer: TimerState;
@@ -38,12 +44,20 @@ interface Store {
// Device ID // Device ID
deviceId: string; deviceId: string;
// Shortcuts state
shortcuts: ShortcutsState;
// Actions // Actions
setTimer: (timer: Partial<TimerState>) => void; setTimer: (timer: Partial<TimerState>) => void;
updateSettings: (settings: Partial<Settings>) => void; updateSettings: (settings: Partial<Settings>) => void;
addLocalSession: (session: Session) => void; addLocalSession: (session: Session) => void;
clearLocalSessions: () => void; clearLocalSessions: () => void;
setDeviceId: (deviceId: string) => void; setDeviceId: (deviceId: string) => void;
// Shortcuts actions
showShortcut: (shortcut: string) => void;
hideShortcut: () => void;
toggleShortcuts: () => void;
} }
const defaultSettings: Settings = { const defaultSettings: Settings = {
@@ -54,8 +68,8 @@ const defaultSettings: Settings = {
autoStartBreaks: true, autoStartBreaks: true,
soundEnabled: true, soundEnabled: true,
soundVolume: 0.7, soundVolume: 0.7,
soundType: 'chime', soundType: "chime",
theme: 'light', theme: "light",
animationsEnabled: true, animationsEnabled: true,
}; };
@@ -63,18 +77,25 @@ const defaultTimer: TimerState = {
isRunning: false, isRunning: false,
currentTime: 25 * 60, currentTime: 25 * 60,
totalTime: 25 * 60, totalTime: 25 * 60,
phase: 'focus', phase: "focus",
sessionCount: 0, sessionCount: 0,
interruptions: 0, interruptions: 0,
}; };
const defaultShortcuts: ShortcutsState = {
lastShortcut: null,
isVisible: false,
shortcutsEnabled: true,
};
export const useStore = create<Store>()( export const useStore = create<Store>()(
persist( persist(
(set, get) => ({ (set, get) => ({
timer: defaultTimer, timer: defaultTimer,
settings: defaultSettings, settings: defaultSettings,
localSessions: [], localSessions: [],
deviceId: '', deviceId: "",
shortcuts: defaultShortcuts,
setTimer: (timerUpdate) => setTimer: (timerUpdate) =>
set((state) => ({ set((state) => ({
@@ -100,14 +121,40 @@ export const useStore = create<Store>()(
set(() => ({ set(() => ({
deviceId, deviceId,
})), })),
showShortcut: (shortcut: string) =>
set((state) => ({
shortcuts: {
...state.shortcuts,
lastShortcut: shortcut,
isVisible: true,
},
})),
hideShortcut: () =>
set((state) => ({
shortcuts: {
...state.shortcuts,
isVisible: false,
},
})),
toggleShortcuts: () =>
set((state) => ({
shortcuts: {
...state.shortcuts,
shortcutsEnabled: !state.shortcuts.shortcutsEnabled,
},
})),
}), }),
{ {
name: 'pomodorian-storage', name: "pomodorian-storage",
partialize: (state) => ({ partialize: (state) => ({
settings: state.settings, settings: state.settings,
localSessions: state.localSessions, localSessions: state.localSessions,
deviceId: state.deviceId, deviceId: state.deviceId,
shortcuts: state.shortcuts,
}), }),
} },
) ),
); );

View File

@@ -29,7 +29,7 @@ export function ThemeProvider({
...props ...props
}: ThemeProviderProps) { }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>( const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
); );
useEffect(() => { useEffect(() => {

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View File

@@ -2,4 +2,10 @@ import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
createRoot(document.getElementById("root")!).render(<App />); const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
const root = createRoot(rootElement);
root.render(<App />);

View File

@@ -2,7 +2,13 @@ import { useState } from "react";
import { MainLayout } from "@/components/templates/MainLayout"; import { MainLayout } from "@/components/templates/MainLayout";
import { HistoryChart } from "@/components/organisms/HistoryChart"; import { HistoryChart } from "@/components/organisms/HistoryChart";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/atoms/Button"; import { Button } from "@/components/atoms/Button";
import { Download, Clock, CheckCircle, Brain, Flame } from "lucide-react"; import { Download, Clock, CheckCircle, Brain, Flame } from "lucide-react";
import { useStore } from "@/lib/store"; import { useStore } from "@/lib/store";
@@ -14,21 +20,24 @@ export default function History() {
const getSessionsInRange = (days: number) => { const getSessionsInRange = (days: number) => {
const now = new Date(); const now = new Date();
const startDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); const startDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
return localSessions.filter(session => return localSessions.filter(
new Date(session.startedAt) >= startDate (session) => new Date(session.startedAt) >= startDate,
); );
}; };
const calculateStats = (sessions: any[]) => { const calculateStats = (sessions: any[]) => {
const focusSessions = sessions.filter(s => s.type === 'focus'); const focusSessions = sessions.filter((s) => s.type === "focus");
const completed = focusSessions.filter(s => s.completed); const completed = focusSessions.filter((s) => s.completed);
return { return {
totalSessions: focusSessions.length, totalSessions: focusSessions.length,
completionRate: focusSessions.length > 0 ? completed.length / focusSessions.length : 0, completionRate:
avgFocusTime: completed.length > 0 focusSessions.length > 0 ? completed.length / focusSessions.length : 0,
? completed.reduce((sum, s) => sum + s.intendedMinutes, 0) / completed.length avgFocusTime:
: 0, completed.length > 0
? completed.reduce((sum, s) => sum + s.intendedMinutes, 0) /
completed.length
: 0,
}; };
}; };
@@ -48,15 +57,20 @@ export default function History() {
const dayEnd = new Date(date); const dayEnd = new Date(date);
dayEnd.setHours(23, 59, 59, 999); dayEnd.setHours(23, 59, 59, 999);
const daySessions = localSessions.filter(session => { const daySessions = localSessions.filter((session) => {
const sessionDate = new Date(session.startedAt); const sessionDate = new Date(session.startedAt);
return sessionDate >= date && sessionDate <= dayEnd && return (
session.type === 'focus' && session.completed; sessionDate >= date &&
sessionDate <= dayEnd &&
session.type === "focus" &&
session.completed
);
}); });
if (daySessions.length > 0) { if (daySessions.length > 0) {
streak++; streak++;
} else if (i > 0) { // Don't break streak on current day if no sessions yet } else if (i > 0) {
// Don't break streak on current day if no sessions yet
break; break;
} }
} }
@@ -70,25 +84,34 @@ export default function History() {
const sessionsToExport = getSessionsInRange(rangeInDays); const sessionsToExport = getSessionsInRange(rangeInDays);
// CSV Export // CSV Export
const headers = ['Date', 'Type', 'Intended Minutes', 'Actual Seconds', 'Completed', 'Interruptions']; const headers = [
"Date",
"Type",
"Intended Minutes",
"Actual Seconds",
"Completed",
"Interruptions",
];
const csvContent = [ const csvContent = [
headers.join(','), headers.join(","),
...sessionsToExport.map(session => [ ...sessionsToExport.map((session) =>
new Date(session.startedAt).toLocaleString(), [
session.type, new Date(session.startedAt).toLocaleString(),
session.intendedMinutes, session.type,
session.actualSeconds, session.intendedMinutes,
session.completed, session.actualSeconds,
session.interruptions session.completed,
].join(',')) session.interruptions,
].join('\n'); ].join(","),
),
].join("\n");
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement('a'); const link = document.createElement("a");
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
link.setAttribute('href', url); link.setAttribute("href", url);
link.setAttribute('download', `pomodorian-sessions-${timeRange}d.csv`); link.setAttribute("download", `pomodorian-sessions-${timeRange}d.csv`);
link.style.visibility = 'hidden'; link.style.visibility = "hidden";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
@@ -97,7 +120,7 @@ export default function History() {
const formatDuration = (seconds: number) => { const formatDuration = (seconds: number) => {
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
}; };
const formatDateTime = (dateStr: string) => { const formatDateTime = (dateStr: string) => {
@@ -106,9 +129,9 @@ export default function History() {
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
if (date.toDateString() === today.toDateString()) { if (date.toDateString() === today.toDateString()) {
return `Today, ${date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`; return `Today, ${date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
} else if (date.toDateString() === yesterday.toDateString()) { } else if (date.toDateString() === yesterday.toDateString()) {
return `Yesterday, ${date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`; return `Yesterday, ${date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
} }
return date.toLocaleString(); return date.toLocaleString();
}; };
@@ -121,11 +144,13 @@ export default function History() {
<div className="p-6 max-w-6xl mx-auto space-y-8 fade-in"> <div className="p-6 max-w-6xl mx-auto space-y-8 fade-in">
{/* Page Header */} {/* Page Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="min-w-0 flex-1">
<h2 className="text-2xl font-bold">Session History</h2> <h2 className="text-2xl font-bold break-words">Session History</h2>
<p className="text-muted-foreground">Track your productivity trends and insights</p> <p className="text-muted-foreground break-words">
Track your productivity trends and insights
</p>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3 flex-shrink-0 ml-4">
<Select value={timeRange} onValueChange={setTimeRange}> <Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue /> <SelectValue />
@@ -152,8 +177,13 @@ export default function History() {
<Clock className="text-primary" /> <Clock className="text-primary" />
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Total Sessions</p> <p className="text-sm text-muted-foreground">
<p className="text-2xl font-bold" data-testid="text-total-sessions"> Total Sessions
</p>
<p
className="text-2xl font-bold"
data-testid="text-total-sessions"
>
{stats.totalSessions} {stats.totalSessions}
</p> </p>
</div> </div>
@@ -168,8 +198,13 @@ export default function History() {
<CheckCircle className="text-chart-2" /> <CheckCircle className="text-chart-2" />
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Completion Rate</p> <p className="text-sm text-muted-foreground">
<p className="text-2xl font-bold" data-testid="text-completion-rate"> Completion Rate
</p>
<p
className="text-2xl font-bold"
data-testid="text-completion-rate"
>
{Math.round(stats.completionRate * 100)}% {Math.round(stats.completionRate * 100)}%
</p> </p>
</div> </div>
@@ -184,8 +219,13 @@ export default function History() {
<Brain className="text-accent" /> <Brain className="text-accent" />
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Avg Focus Time</p> <p className="text-sm text-muted-foreground">
<p className="text-2xl font-bold" data-testid="text-avg-focus"> Avg Focus Time
</p>
<p
className="text-2xl font-bold"
data-testid="text-avg-focus"
>
{Math.round(stats.avgFocusTime)}m {Math.round(stats.avgFocusTime)}m
</p> </p>
</div> </div>
@@ -200,8 +240,13 @@ export default function History() {
<Flame className="text-chart-4" /> <Flame className="text-chart-4" />
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Current Streak</p> <p className="text-sm text-muted-foreground">
<p className="text-2xl font-bold" data-testid="text-current-streak"> Current Streak
</p>
<p
className="text-2xl font-bold"
data-testid="text-current-streak"
>
{currentStreak} days {currentStreak} days
</p> </p>
</div> </div>
@@ -247,25 +292,32 @@ export default function History() {
{formatDateTime(session.startedAt.toString())} {formatDateTime(session.startedAt.toString())}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ <span
session.type === 'focus' className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
? 'bg-primary/10 text-primary' session.type === "focus"
: 'bg-accent/10 text-accent' ? "bg-primary/10 text-primary"
}`}> : "bg-accent/10 text-accent"
{session.type === 'focus' ? 'Focus' : 'Break'} }`}
>
{session.type === "focus" ? "Focus" : "Break"}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm"> <td className="px-6 py-4 whitespace-nowrap text-sm">
{session.intendedMinutes}:00 / {formatDuration(session.actualSeconds)} {session.intendedMinutes}:00 /{" "}
{formatDuration(session.actualSeconds)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ <span
session.completed className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
? 'bg-chart-2/10 text-chart-2' session.completed
: 'bg-destructive/10 text-destructive' ? "bg-chart-2/10 text-chart-2"
}`}> : "bg-destructive/10 text-destructive"
<CheckCircle className={`mr-1 w-3 h-3 ${session.completed ? '' : 'hidden'}`} /> }`}
{session.completed ? 'Completed' : 'Interrupted'} >
<CheckCircle
className={`mr-1 w-3 h-3 ${session.completed ? "" : "hidden"}`}
/>
{session.completed ? "Completed" : "Interrupted"}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground"> <td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
@@ -275,7 +327,10 @@ export default function History() {
))} ))}
{sessionsInRange.length === 0 && ( {sessionsInRange.length === 0 && (
<tr> <tr>
<td colSpan={5} className="px-6 py-8 text-center text-muted-foreground"> <td
colSpan={5}
className="px-6 py-8 text-center text-muted-foreground"
>
No sessions found for the selected time range. No sessions found for the selected time range.
</td> </td>
</tr> </tr>

View File

@@ -41,10 +41,10 @@ export default function Home() {
const handleDurationChange = (focus: number, breakDuration: number) => { const handleDurationChange = (focus: number, breakDuration: number) => {
updateSettings({ updateSettings({
focusDuration: focus, focusDuration: focus,
shortBreakDuration: breakDuration shortBreakDuration: breakDuration,
}); });
if (timer.phase === 'focus') { if (timer.phase === "focus") {
const durationSeconds = focus * 60; const durationSeconds = focus * 60;
setTimer({ setTimer({
currentTime: durationSeconds, currentTime: durationSeconds,
@@ -55,13 +55,14 @@ export default function Home() {
const handleExportData = () => { const handleExportData = () => {
const dataStr = JSON.stringify(localSessions, null, 2); const dataStr = JSON.stringify(localSessions, null, 2);
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr); const dataUri =
"data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
const exportFileDefaultName = `pomodorian-sessions-${new Date().toISOString().split('T')[0]}.json`; const exportFileDefaultName = `pomodorian-sessions-${new Date().toISOString().split("T")[0]}.json`;
const linkElement = document.createElement('a'); const linkElement = document.createElement("a");
linkElement.setAttribute('href', dataUri); linkElement.setAttribute("href", dataUri);
linkElement.setAttribute('download', exportFileDefaultName); linkElement.setAttribute("download", exportFileDefaultName);
linkElement.click(); linkElement.click();
}; };
@@ -69,18 +70,22 @@ export default function Home() {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const todaySessions = localSessions.filter(session => { const todaySessions = localSessions.filter((session) => {
const sessionDate = new Date(session.startedAt); const sessionDate = new Date(session.startedAt);
sessionDate.setHours(0, 0, 0, 0); sessionDate.setHours(0, 0, 0, 0);
return sessionDate.getTime() === today.getTime(); return sessionDate.getTime() === today.getTime();
}); });
const todayFocusSessions = todaySessions.filter(s => s.type === 'focus'); const todayFocusSessions = todaySessions.filter((s) => s.type === "focus");
const completedToday = todayFocusSessions.filter(s => s.completed); const completedToday = todayFocusSessions.filter((s) => s.completed);
const todayFocusTime = completedToday.reduce((sum, s) => sum + s.intendedMinutes, 0); const todayFocusTime = completedToday.reduce(
const completionRate = todayFocusSessions.length > 0 (sum, s) => sum + s.intendedMinutes,
? Math.round((completedToday.length / todayFocusSessions.length) * 100) 0,
: 0; );
const completionRate =
todayFocusSessions.length > 0
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
: 0;
const formatFocusTime = (minutes: number) => { const formatFocusTime = (minutes: number) => {
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
@@ -104,7 +109,57 @@ export default function Home() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Timer Circle */} {/* Timer Circle */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
{ /* Pomodoro Timer Component */ }
<PomodoroTimer /> <PomodoroTimer />
{/* Today's Progress */}
<Card className="mt-6">
<CardHeader>
<CardTitle>Today's Progress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground break-words">
Sessions Completed
</span>
<span
className="font-medium flex-shrink-0 ml-2"
data-testid="text-today-completed"
>
{completedToday.length}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground break-words">
Focus Time
</span>
<span
className="font-medium flex-shrink-0 ml-2"
data-testid="text-today-focus-time"
>
{formatFocusTime(todayFocusTime)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground break-words">
Completion Rate
</span>
<span
className="font-medium text-chart-2 flex-shrink-0 ml-2"
data-testid="text-today-completion-rate"
>
{completionRate}%
</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-chart-2 h-2 rounded-full transition-all"
style={{ width: `${completionRate}%` }}
data-testid="progress-today-completion"
/>
</div>
</CardContent>
</Card>
</div> </div>
{/* Duration Selector & Settings */} {/* Duration Selector & Settings */}
@@ -124,64 +179,36 @@ export default function Home() {
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start space-x-3 h-auto p-3 hover:border-accent hover:bg-accent/5" className="w-full justify-start space-x-3 h-auto p-3 hover:border-accent hover:bg-accent/5 min-h-[80px]"
data-testid="button-sound-settings" data-testid="button-sound-settings"
> >
<Volume2 className="text-accent" /> <Volume2 className="text-accent flex-shrink-0" />
<div className="text-left"> <div className="text-left flex-1 min-w-0">
<div className="font-medium">Sound Settings</div> <div className="font-medium break-words">
<div className="text-sm text-muted-foreground">Adjust notification sounds</div> Sound Settings
</div>
<div className="text-sm text-muted-foreground break-words leading-relaxed">
Adjust notification sounds
</div>
</div> </div>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start space-x-3 h-auto p-3 hover:border-chart-2 hover:bg-chart-2/5" className="w-full justify-start space-x-3 h-auto p-3 hover:border-chart-2 hover:bg-chart-2/5 min-h-[80px]"
onClick={handleExportData} onClick={handleExportData}
data-testid="button-export-data" data-testid="button-export-data"
> >
<Download className="text-chart-2" /> <Download className="text-chart-2 flex-shrink-0" />
<div className="text-left"> <div className="text-left flex-1 min-w-0">
<div className="font-medium">Export Data</div> <div className="font-medium break-words">Export Data</div>
<div className="text-sm text-muted-foreground">Download session history</div> <div className="text-sm text-muted-foreground break-words leading-relaxed">
Download session history
</div>
</div> </div>
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{/* Today's Progress */}
<Card>
<CardHeader>
<CardTitle>Today's Progress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Sessions Completed</span>
<span className="font-medium" data-testid="text-today-completed">
{completedToday.length}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Focus Time</span>
<span className="font-medium" data-testid="text-today-focus-time">
{formatFocusTime(todayFocusTime)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Completion Rate</span>
<span className="font-medium text-chart-2" data-testid="text-today-completion-rate">
{completionRate}%
</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-chart-2 h-2 rounded-full transition-all"
style={{ width: `${completionRate}%` }}
data-testid="progress-today-completion"
/>
</div>
</CardContent>
</Card>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,13 @@ import { MainLayout } from "@/components/templates/MainLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { Button } from "@/components/atoms/Button"; import { Button } from "@/components/atoms/Button";
@@ -20,7 +26,7 @@ import {
FileText, FileText,
Trash2, Trash2,
Play, Play,
Keyboard Keyboard,
} from "lucide-react"; } from "lucide-react";
import { useStore } from "@/lib/store"; import { useStore } from "@/lib/store";
import { useTheme } from "@/lib/theme"; import { useTheme } from "@/lib/theme";
@@ -60,43 +66,57 @@ export default function Settings() {
}; };
const dataStr = JSON.stringify(data, null, 2); const dataStr = JSON.stringify(data, null, 2);
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr); const dataUri =
"data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
const exportFileDefaultName = `pomodorian-export-${new Date().toISOString().split('T')[0]}.json`; const exportFileDefaultName = `pomodorian-export-${new Date().toISOString().split("T")[0]}.json`;
const linkElement = document.createElement('a'); const linkElement = document.createElement("a");
linkElement.setAttribute('href', dataUri); linkElement.setAttribute("href", dataUri);
linkElement.setAttribute('download', exportFileDefaultName); linkElement.setAttribute("download", exportFileDefaultName);
linkElement.click(); linkElement.click();
}; };
const handleExportCSV = () => { const handleExportCSV = () => {
const headers = ['Date', 'Type', 'Intended Minutes', 'Actual Seconds', 'Completed', 'Interruptions']; const headers = [
"Date",
"Type",
"Intended Minutes",
"Actual Seconds",
"Completed",
"Interruptions",
];
const csvContent = [ const csvContent = [
headers.join(','), headers.join(","),
...localSessions.map(session => [ ...localSessions.map((session) =>
new Date(session.startedAt).toLocaleString(), [
session.type, new Date(session.startedAt).toLocaleString(),
session.intendedMinutes, session.type,
session.actualSeconds, session.intendedMinutes,
session.completed, session.actualSeconds,
session.interruptions session.completed,
].join(',')) session.interruptions,
].join('\n'); ].join(","),
),
].join("\n");
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement('a'); const link = document.createElement("a");
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
link.setAttribute('href', url); link.setAttribute("href", url);
link.setAttribute('download', `pomodorian-sessions.csv`); link.setAttribute("download", `pomodorian-sessions.csv`);
link.style.visibility = 'hidden'; link.style.visibility = "hidden";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
}; };
const handleClearData = () => { const handleClearData = () => {
if (confirm('Are you sure you want to clear all data? This action cannot be undone.')) { if (
confirm(
"Are you sure you want to clear all data? This action cannot be undone.",
)
) {
clearLocalSessions(); clearLocalSessions();
toast({ toast({
title: "Data cleared", title: "Data cleared",
@@ -115,10 +135,7 @@ export default function Settings() {
}; };
return ( return (
<MainLayout <MainLayout title="Settings" subtitle="Customize your Pomodoro experience">
title="Settings"
subtitle="Customize your Pomodoro experience"
>
<div className="p-6 max-w-4xl mx-auto space-y-8 fade-in"> <div className="p-6 max-w-4xl mx-auto space-y-8 fade-in">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Timer Settings */} {/* Timer Settings */}
@@ -138,33 +155,49 @@ export default function Settings() {
value={settings.focusDuration} value={settings.focusDuration}
min="1" min="1"
max="120" max="120"
onChange={(e) => updateSettings({ focusDuration: parseInt(e.target.value) || 25 })} onChange={(e) =>
updateSettings({
focusDuration: parseInt(e.target.value) || 25,
})
}
data-testid="input-focus-duration" data-testid="input-focus-duration"
/> />
</div> </div>
<div> <div>
<Label htmlFor="short-break-duration">Short Break Duration (minutes)</Label> <Label htmlFor="short-break-duration">
Short Break Duration (minutes)
</Label>
<Input <Input
id="short-break-duration" id="short-break-duration"
type="number" type="number"
value={settings.shortBreakDuration} value={settings.shortBreakDuration}
min="1" min="1"
max="30" max="30"
onChange={(e) => updateSettings({ shortBreakDuration: parseInt(e.target.value) || 5 })} onChange={(e) =>
updateSettings({
shortBreakDuration: parseInt(e.target.value) || 5,
})
}
data-testid="input-short-break-duration" data-testid="input-short-break-duration"
/> />
</div> </div>
<div> <div>
<Label htmlFor="long-break-duration">Long Break Duration (minutes)</Label> <Label htmlFor="long-break-duration">
Long Break Duration (minutes)
</Label>
<Input <Input
id="long-break-duration" id="long-break-duration"
type="number" type="number"
value={settings.longBreakDuration} value={settings.longBreakDuration}
min="5" min="5"
max="60" max="60"
onChange={(e) => updateSettings({ longBreakDuration: parseInt(e.target.value) || 15 })} onChange={(e) =>
updateSettings({
longBreakDuration: parseInt(e.target.value) || 15,
})
}
data-testid="input-long-break-duration" data-testid="input-long-break-duration"
/> />
</div> </div>
@@ -173,7 +206,9 @@ export default function Settings() {
<Label htmlFor="long-break-interval">Long Break Interval</Label> <Label htmlFor="long-break-interval">Long Break Interval</Label>
<Select <Select
value={settings.longBreakInterval.toString()} value={settings.longBreakInterval.toString()}
onValueChange={(value) => updateSettings({ longBreakInterval: parseInt(value) })} onValueChange={(value) =>
updateSettings({ longBreakInterval: parseInt(value) })
}
> >
<SelectTrigger data-testid="select-long-break-interval"> <SelectTrigger data-testid="select-long-break-interval">
<SelectValue /> <SelectValue />
@@ -189,12 +224,16 @@ export default function Settings() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Label htmlFor="auto-start-breaks">Auto-start breaks</Label> <Label htmlFor="auto-start-breaks">Auto-start breaks</Label>
<p className="text-sm text-muted-foreground">Automatically start break timer</p> <p className="text-sm text-muted-foreground">
Automatically start break timer
</p>
</div> </div>
<Switch <Switch
id="auto-start-breaks" id="auto-start-breaks"
checked={settings.autoStartBreaks} checked={settings.autoStartBreaks}
onCheckedChange={(checked) => updateSettings({ autoStartBreaks: checked })} onCheckedChange={(checked) =>
updateSettings({ autoStartBreaks: checked })
}
data-testid="switch-auto-start-breaks" data-testid="switch-auto-start-breaks"
/> />
</div> </div>
@@ -213,12 +252,16 @@ export default function Settings() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Label htmlFor="sound-enabled">Enable notifications</Label> <Label htmlFor="sound-enabled">Enable notifications</Label>
<p className="text-sm text-muted-foreground">Play sound when sessions end</p> <p className="text-sm text-muted-foreground">
Play sound when sessions end
</p>
</div> </div>
<Switch <Switch
id="sound-enabled" id="sound-enabled"
checked={settings.soundEnabled} checked={settings.soundEnabled}
onCheckedChange={(checked) => updateSettings({ soundEnabled: checked })} onCheckedChange={(checked) =>
updateSettings({ soundEnabled: checked })
}
data-testid="switch-sound-enabled" data-testid="switch-sound-enabled"
/> />
</div> </div>
@@ -227,7 +270,9 @@ export default function Settings() {
<Label htmlFor="sound-type">Notification Sound</Label> <Label htmlFor="sound-type">Notification Sound</Label>
<Select <Select
value={settings.soundType} value={settings.soundType}
onValueChange={(value) => updateSettings({ soundType: value })} onValueChange={(value) =>
updateSettings({ soundType: value })
}
> >
<SelectTrigger data-testid="select-sound-type"> <SelectTrigger data-testid="select-sound-type">
<SelectValue /> <SelectValue />
@@ -282,28 +327,64 @@ export default function Settings() {
<Label>Theme</Label> <Label>Theme</Label>
<div className="grid grid-cols-2 gap-3 mt-2"> <div className="grid grid-cols-2 gap-3 mt-2">
<Button <Button
variant={theme === 'light' ? 'default' : 'outline'} variant={theme === "light" ? "default" : "outline"}
className="h-auto p-3 text-left" className="h-auto p-3 text-left min-h-[80px] hover:bg-[#f1f5f9] transition-colors group"
onClick={() => setTheme('light')} onClick={() => setTheme("light")}
data-testid="button-theme-light" data-testid="button-theme-light"
> >
<div className="flex items-center space-x-2 mb-2"> <div className="flex items-center space-x-2">
<Sun className="text-primary" /> <Sun
<span className="font-medium">Light</span> className={`flex-shrink-0 transition-colors ${
theme === "light" ? "text-white" : "text-current"
} group-hover:text-black`}
/>
<div className="flex flex-col">
<span
className={`font-medium break-words transition-colors ${
theme === "light" ? "text-white" : "text-current"
} group-hover:text-black`}
>
Light
</span>
<div
className={`text-xs text-muted-foreground break-words leading-relaxed transition-colors ${
theme === "light" ? "text-white" : "text-current"
} group-hover:text-black`}
>
Clean and bright
</div>
</div>
</div> </div>
<div className="text-xs text-muted-foreground">Clean and bright</div>
</Button> </Button>
<Button <Button
variant={theme === 'dark' ? 'default' : 'outline'} variant={theme === "dark" ? "default" : "outline"}
className="h-auto p-3 text-left" className="h-auto p-3 text-left min-h-[80px] hover:bg-[#f1f5f9] transition-colors group"
onClick={() => setTheme('dark')} onClick={() => setTheme("dark")}
data-testid="button-theme-dark" data-testid="button-theme-dark"
> >
<div className="flex items-center space-x-2 mb-2"> <div className="flex items-center space-x-2">
<Moon /> <Moon
<span className="font-medium">Dark</span> className={`flex-shrink-0 transition-colors ${
theme === "dark" ? "text-white" : "text-current"
} group-hover:text-black`}
/>
<div className="flex flex-col">
<span
className={`font-medium break-words transition-colors ${
theme === "dark" ? "text-white" : "text-current"
} group-hover:text-black`}
>
Dark
</span>
<div
className={`text-xs text-muted-foreground break-words leading-relaxed transition-colors ${
theme === "dark" ? "text-white" : "text-current"
} group-hover:text-black`}
>
Easy on the eyes
</div>
</div>
</div> </div>
<div className="text-xs text-muted-foreground">Easy on the eyes</div>
</Button> </Button>
</div> </div>
</div> </div>
@@ -311,12 +392,16 @@ export default function Settings() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Label htmlFor="animations">Animations</Label> <Label htmlFor="animations">Animations</Label>
<p className="text-sm text-muted-foreground">Enable UI animations</p> <p className="text-sm text-muted-foreground">
Enable UI animations
</p>
</div> </div>
<Switch <Switch
id="animations" id="animations"
checked={settings.animationsEnabled} checked={settings.animationsEnabled}
onCheckedChange={(checked) => updateSettings({ animationsEnabled: checked })} onCheckedChange={(checked) =>
updateSettings({ animationsEnabled: checked })
}
data-testid="switch-animations" data-testid="switch-animations"
/> />
</div> </div>
@@ -334,9 +419,14 @@ export default function Settings() {
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div> <div>
<Label>Anonymous Device ID</Label> <Label>Anonymous Device ID</Label>
<p className="text-sm text-muted-foreground mb-2">Used for smart suggestions and analytics</p> <p className="text-sm text-muted-foreground mb-2 break-words">
Used for smart suggestions and analytics
</p>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<code className="px-2 py-1 bg-muted rounded text-xs font-mono flex-1 truncate" data-testid="text-device-id"> <code
className="px-2 py-1 bg-muted rounded text-xs font-mono flex-1 truncate break-all"
data-testid="text-device-id"
>
{deviceId} {deviceId}
</code> </code>
<Button <Button
@@ -344,6 +434,7 @@ export default function Settings() {
size="sm" size="sm"
onClick={handleRegenerateDeviceId} onClick={handleRegenerateDeviceId}
data-testid="button-regenerate-device-id" data-testid="button-regenerate-device-id"
className="flex-shrink-0"
> >
Regenerate Regenerate
</Button> </Button>
@@ -353,32 +444,50 @@ export default function Settings() {
<div className="space-y-3"> <div className="space-y-3">
<Button <Button
variant="default" variant="default"
className="w-full bg-chart-2 text-chart-2-foreground hover:bg-chart-2/90" className="w-full bg-chart-2 text-chart-2-foreground hover:bg-chart-2/90 h-auto p-3 text-left"
onClick={handleExportJSON} onClick={handleExportJSON}
data-testid="button-export-json" data-testid="button-export-json"
> >
<Download className="w-4 h-4 mr-2" /> <div className="flex items-center space-x-2">
Export All Data (JSON) <Download className="w-4 h-4 flex-shrink-0" />
<div className="flex flex-col">
<span className="font-medium break-words">
Export All Data (JSON)
</span>
</div>
</div>
</Button> </Button>
<Button <Button
variant="default" variant="default"
className="w-full bg-accent text-accent-foreground hover:bg-accent/90" className="w-full bg-accent text-accent-foreground hover:bg-accent/90 h-auto p-3 text-left"
onClick={handleExportCSV} onClick={handleExportCSV}
data-testid="button-export-csv" data-testid="button-export-csv"
> >
<FileText className="w-4 h-4 mr-2" /> <div className="flex items-center space-x-2">
Export Sessions (CSV) <FileText className="w-4 h-4 flex-shrink-0" />
<div className="flex flex-col">
<span className="font-medium break-words">
Export Sessions (CSV)
</span>
</div>
</div>
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
className="w-full" className="w-full h-auto p-3 text-left"
onClick={handleClearData} onClick={handleClearData}
data-testid="button-clear-data" data-testid="button-clear-data"
> >
<Trash2 className="w-4 h-4 mr-2" /> <div className="flex items-center space-x-2">
Clear All Data <Trash2 className="w-4 h-4 flex-shrink-0" />
<div className="flex flex-col">
<span className="font-medium break-words">
Clear All Data
</span>
</div>
</div>
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -394,22 +503,47 @@ export default function Settings() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="space-y-4">
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between">
<span>Start/Pause Timer</span> <div>
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">Space</kbd> <Label htmlFor="shortcuts-enabled">Enable Keyboard Shortcuts</Label>
<p className="text-sm text-muted-foreground">Allow keyboard shortcuts throughout the app</p>
</div>
<Switch
id="shortcuts-enabled"
checked={useStore((state) => state.shortcuts.shortcutsEnabled)}
onCheckedChange={useStore((state) => state.toggleShortcuts)}
data-testid="switch-shortcuts-enabled"
/>
</div> </div>
<div className="flex items-center justify-between py-2"> <p className="text-sm text-muted-foreground">
<span>Reset Timer</span> These shortcuts are active throughout the app. Try them now!
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">R</kbd> </p>
</div> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<span>Skip Break</span> <span className="break-words">Start/Pause Timer</span>
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">S</kbd> <kbd className="px-2 py-1 bg-muted rounded text-sm font-mono flex-shrink-0 ml-2">
</div> Space
<div className="flex items-center justify-between py-2"> </kbd>
<span>Toggle Theme</span> </div>
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">Ctrl + D</kbd> <div className="flex items-center justify-between py-2">
<span className="break-words">Reset Timer</span>
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono flex-shrink-0 ml-2">
R
</kbd>
</div>
<div className="flex items-center justify-between py-2">
<span className="break-words">Skip Break</span>
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono flex-shrink-0 ml-2">
S
</kbd>
</div>
<div className="flex items-center justify-between py-2">
<span className="break-words">Toggle Theme</span>
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono flex-shrink-0 ml-2">
Ctrl + D
</kbd>
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -8,7 +8,9 @@ export default function NotFound() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex mb-4 gap-2"> <div className="flex mb-4 gap-2">
<AlertCircle className="h-8 w-8 text-red-500" /> <AlertCircle className="h-8 w-8 text-red-500" />
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1> <h1 className="text-2xl font-bold text-gray-900">
404 Page Not Found
</h1>
</div> </div>
<p className="mt-4 text-sm text-gray-600"> <p className="mt-4 text-sm text-gray-600">

3830
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "NODE_ENV=development tsx server/index.ts", "dev": "NODE_ENV=development tsx server/index.ts",
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist", "build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start:build": "npm run build && NODE_ENV=production pm2 start dist/index.js --name \"pomodoro\" --env production",
"start": "NODE_ENV=production node dist/index.js", "start": "NODE_ENV=production node dist/index.js",
"check": "tsc", "check": "tsc",
"db:push": "drizzle-kit push" "db:push": "drizzle-kit push"
@@ -14,33 +15,33 @@
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4", "@neondatabase/serverless": "^0.10.4",
"@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.3", "@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.1.5", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.7", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.7", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.3", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.7", "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.6", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.7", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.3", "@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.2.4", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.4", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.1.7", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.2.4", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.4", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.4", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.7", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.3", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.0", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.60.5", "@tanstack/react-query": "^5.60.5",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -48,12 +49,13 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "^10.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dotenv": "^17.2.1",
"drizzle-orm": "^0.39.1", "drizzle-orm": "^0.39.1",
"drizzle-zod": "^0.7.0", "drizzle-zod": "^0.7.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"express": "^4.21.2", "express": "^4.21.2",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"framer-motion": "^11.13.1", "framer-motion": "^12.23.12",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
@@ -61,13 +63,13 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"react": "^18.3.1", "react": "^19.1.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^9.9.0",
"react-dom": "^18.3.1", "react-dom": "^19.1.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^3.1.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.2.5", "tw-animate-css": "^1.2.5",
@@ -80,20 +82,19 @@
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@replit/vite-plugin-cartographer": "^0.3.0",
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3", "@tailwindcss/vite": "^4.1.3",
"@types/connect-pg-simple": "^7.0.3", "@types/connect-pg-simple": "^7.0.3",
"@types/dotenv": "^6.1.1",
"@types/express": "4.17.21", "@types/express": "4.17.21",
"@types/express-session": "^1.18.0", "@types/express-session": "^1.18.0",
"@types/node": "20.16.11", "@types/node": "20.16.11",
"@types/passport": "^1.0.16", "@types/passport": "^1.0.16",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/react": "^18.3.11", "@types/react": "^19.1.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^19.1.9",
"@types/ws": "^8.5.13", "@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"drizzle-kit": "^0.30.4", "drizzle-kit": "^0.30.4",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",

View File

@@ -1,76 +0,0 @@
# Overview
Pomodorian is a full-stack Pomodoro timer application designed to help users focus and manage their work sessions effectively. The app provides timer functionality with customizable durations, smart time suggestions based on usage patterns, and session history tracking. Built without user authentication, it uses anonymous device IDs to provide personalized features while maintaining privacy.
# User Preferences
Preferred communication style: Simple, everyday language.
# System Architecture
## Frontend Architecture
- **Framework**: React with TypeScript and Vite for fast development and building
- **UI Components**: Shadcn/UI component library with Radix UI primitives and Tailwind CSS for styling
- **State Management**: Zustand for global state management with persistence
- **Routing**: Wouter for lightweight client-side routing
- **Data Fetching**: TanStack Query (React Query) for server state management and caching
- **Component Structure**: Atomic Design pattern (atoms/molecules/organisms/templates/pages)
## Backend Architecture
- **Framework**: Express.js with TypeScript following MVC pattern
- **Development Setup**: Vite integration for hot module replacement in development
- **Database**: Drizzle ORM with PostgreSQL for data persistence
- **Storage**: Neon Database as the PostgreSQL provider
- **Session Management**: In-memory storage with planned database persistence
## Key Features Implementation
- **Timer System**: Real-time Pomodoro timer with focus/break phases, visual progress ring, and audio notifications
- **Smart Suggestions**: Algorithm-based duration recommendations using historical session data
- **Anonymous Tracking**: Device ID generation for personalized features without user accounts
- **Local Storage**: Client-side session history with IndexedDB/localStorage fallback
- **Audio Management**: Web Audio API for customizable timer completion sounds
- **Theme Support**: Light/dark mode toggle with system preference detection
## Data Storage Solutions
- **Client Storage**: Local session history stored in browser storage for offline access
- **Server Storage**: PostgreSQL database for aggregated analytics and suggestions
- **Database Schema**: Sessions table tracking duration, completion rates, and device profiles
- **Device Identification**: Anonymous UUID-based device tracking via cookies
## Design Patterns
- **Component Architecture**: Atomic design with reusable UI components
- **State Management**: Zustand stores for timer state, settings, and local sessions
- **API Layer**: RESTful endpoints with structured error handling and validation
- **Type Safety**: Shared TypeScript schemas between client and server using Drizzle Zod
# External Dependencies
## Core Framework Dependencies
- **React Ecosystem**: React 18 with TypeScript, Vite build tool, and Wouter routing
- **UI Framework**: Shadcn/UI components built on Radix UI primitives
- **Styling**: Tailwind CSS with CSS custom properties for theming
## Database and ORM
- **Database**: Neon Database (PostgreSQL-compatible serverless database)
- **ORM**: Drizzle ORM with Drizzle Kit for migrations and schema management
- **Validation**: Zod for runtime type validation and schema generation
## State Management and Data Fetching
- **Global State**: Zustand with persistence middleware for settings and local data
- **Server State**: TanStack Query for API calls, caching, and synchronization
- **Form Handling**: React Hook Form with Hookform Resolvers for validation
## Development and Build Tools
- **Build System**: Vite with React plugin and runtime error overlay
- **TypeScript**: Full TypeScript setup with path aliases and strict configuration
- **Development**: ESBuild for server bundling and hot module replacement
## Audio and Utilities
- **Audio**: Web Audio API for timer completion sounds and volume control
- **Date Handling**: date-fns for date manipulation and formatting
- **UUID Generation**: UUID library for anonymous device identification
- **Styling Utilities**: clsx and class-variance-authority for conditional CSS classes
## Replit Integration
- **Development Environment**: Replit-specific plugins for cartographer and runtime error handling
- **Deployment**: Express server with static file serving for production builds

View File

@@ -2,6 +2,10 @@ import express, { type Request, Response, NextFunction } from "express";
import { registerRoutes } from "./routes"; import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite"; import { setupVite, serveStatic, log } from "./vite";
// Load environment variables from .env file
import dotenv from 'dotenv';
dotenv.config();
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: false })); app.use(express.urlencoded({ extended: false }));
@@ -57,10 +61,17 @@ app.use((req, res, next) => {
} }
// ALWAYS serve the app on the port specified in the environment variable PORT // ALWAYS serve the app on the port specified in the environment variable PORT
// Other ports are firewalled. Default to 5000 if not specified. // Other ports are firewalled. Default to 8004 if not specified.
// this serves both the API and the client. // this serves both the API and the client.
// It is the only port that is not firewalled. // It is the only port that is not firewalled.
const port = parseInt(process.env.PORT || '5000', 10); const port = parseInt(process.env.PORT || '5000', 10);
// Debug: Log environment variables
log(`Environment variables loaded:`);
log(`NODE_ENV: ${process.env.NODE_ENV}`);
log(`PORT: ${process.env.PORT}`);
log(`Using port: ${port}`);
server.listen({ server.listen({
port, port,
host: "0.0.0.0", host: "0.0.0.0",

View File

@@ -8,7 +8,8 @@
"module": "ESNext", "module": "ESNext",
"strict": true, "strict": true,
"lib": ["esnext", "dom", "dom.iterable"], "lib": ["esnext", "dom", "dom.iterable"],
"jsx": "preserve", "jsx": "react-jsx",
"jsxImportSource": "react",
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,

View File

@@ -1,20 +1,13 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "path"; import path from "path";
import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react({
runtimeErrorOverlay(), jsxImportSource: "react",
...(process.env.NODE_ENV !== "production" && jsxRuntime: "automatic",
process.env.REPL_ID !== undefined }),
? [
await import("@replit/vite-plugin-cartographer").then((m) =>
m.cartographer(),
),
]
: []),
], ],
resolve: { resolve: {
alias: { alias: {
@@ -23,7 +16,8 @@ export default defineConfig({
"@assets": path.resolve(import.meta.dirname, "attached_assets"), "@assets": path.resolve(import.meta.dirname, "attached_assets"),
}, },
}, },
root: path.resolve(import.meta.dirname, "client"), root: path.resolve(import.meta.dirname, "client"), // Keep client as root for source files
envDir: path.resolve(import.meta.dirname, "."), // Look for .env in root directory
build: { build: {
outDir: path.resolve(import.meta.dirname, "dist/public"), outDir: path.resolve(import.meta.dirname, "dist/public"),
emptyOutDir: true, emptyOutDir: true,