mirror of
https://github.com/CarGDev/pomodoro.git
synced 2025-09-18 17:28:27 +00:00
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:
147
.gitignore
vendored
147
.gitignore
vendored
@@ -3,4 +3,149 @@ dist
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
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
39
.replit
@@ -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
212
README.md
Normal 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!**
|
||||||
|
|
@@ -1,204 +0,0 @@
|
|||||||
got it — here’s 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 37–42m." },
|
|
||||||
{ "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.
|
|
@@ -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>
|
||||||
|
@@ -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>
|
||||||
|
@@ -5,9 +5,9 @@ interface ProgressRingProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressRing({
|
export function ProgressRing({
|
||||||
progress,
|
progress,
|
||||||
size = 256,
|
size = 256,
|
||||||
strokeWidth = 8,
|
strokeWidth = 8,
|
||||||
className = ""
|
className = ""
|
||||||
}: ProgressRingProps) {
|
}: ProgressRingProps) {
|
||||||
@@ -17,10 +17,10 @@ export function ProgressRing({
|
|||||||
const strokeDashoffset = circumference * (1 - progress);
|
const strokeDashoffset = circumference * (1 - progress);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={`transform -rotate-90 ${className}`}
|
className={`transform -rotate-90 ${className}`}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
data-testid="progress-ring"
|
data-testid="progress-ring"
|
||||||
>
|
>
|
||||||
@@ -34,7 +34,7 @@ export function ProgressRing({
|
|||||||
fill="none"
|
fill="none"
|
||||||
className="text-muted opacity-20"
|
className="text-muted opacity-20"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Progress Circle */}
|
{/* Progress Circle */}
|
||||||
<circle
|
<circle
|
||||||
cx={size / 2}
|
cx={size / 2}
|
||||||
|
22
client/src/components/atoms/ShortcutIndicator.tsx
Normal file
22
client/src/components/atoms/ShortcutIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -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'
|
||||||
|
@@ -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>
|
||||||
|
@@ -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);
|
||||||
@@ -18,23 +22,34 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
|||||||
// Calculate today's stats
|
// Calculate today's stats
|
||||||
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">
|
||||||
@@ -63,15 +80,15 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
|||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const active = isActive(item.path);
|
const active = isActive(item.path);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={item.path} href={item.path}>
|
<Link key={item.path} href={item.path}>
|
||||||
<Button
|
<Button
|
||||||
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>
|
||||||
|
@@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
142
client/src/hooks/use-keyboard-shortcuts.ts
Normal file
142
client/src/hooks/use-keyboard-shortcuts.ts
Normal 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]);
|
||||||
|
}
|
@@ -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;
|
||||||
|
}
|
||||||
|
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,18 +74,21 @@ export class AudioManager {
|
|||||||
try {
|
try {
|
||||||
const source = this.audioContext.createBufferSource();
|
const source = this.audioContext.createBufferSource();
|
||||||
source.buffer = buffer;
|
source.buffer = buffer;
|
||||||
|
|
||||||
this.gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
|
this.gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
|
@@ -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,29 +21,43 @@ 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;
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
|
|
||||||
// Local session history
|
// Local session history
|
||||||
localSessions: Session[];
|
localSessions: Session[];
|
||||||
|
|
||||||
// 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,
|
||||||
}),
|
}),
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
@@ -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(() => {
|
||||||
|
@@ -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));
|
||||||
}
|
}
|
||||||
|
@@ -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 />);
|
||||||
|
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,27 +49,32 @@ export default function History() {
|
|||||||
const calculateStreak = () => {
|
const calculateStreak = () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
let streak = 0;
|
let streak = 0;
|
||||||
|
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
const date = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
|
const date = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
|
||||||
date.setHours(0, 0, 0, 0);
|
date.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return streak;
|
return streak;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,27 +82,36 @@ export default function History() {
|
|||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
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(","),
|
||||||
|
),
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
].join("\n");
|
||||||
const link = document.createElement('a');
|
|
||||||
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||||
|
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,35 +120,37 @@ 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) => {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
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();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout
|
<MainLayout
|
||||||
title="Session History"
|
title="Session History"
|
||||||
subtitle="Track your productivity trends and insights"
|
subtitle="Track your productivity trends and insights"
|
||||||
>
|
>
|
||||||
<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,15 +177,20 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
@@ -168,15 +198,20 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
@@ -184,15 +219,20 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
@@ -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>
|
||||||
|
@@ -39,12 +39,12 @@ 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,32 +55,37 @@ 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');
|
|
||||||
linkElement.setAttribute('href', dataUri);
|
const linkElement = document.createElement("a");
|
||||||
linkElement.setAttribute('download', exportFileDefaultName);
|
linkElement.setAttribute("href", dataUri);
|
||||||
|
linkElement.setAttribute("download", exportFileDefaultName);
|
||||||
linkElement.click();
|
linkElement.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate today's progress
|
// Calculate today's progress
|
||||||
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);
|
||||||
@@ -92,8 +97,8 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout
|
<MainLayout
|
||||||
title="Focus Timer"
|
title="Focus Timer"
|
||||||
subtitle="Stay focused with the Pomodoro Technique"
|
subtitle="Stay focused with the Pomodoro Technique"
|
||||||
>
|
>
|
||||||
<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">
|
||||||
@@ -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>
|
||||||
|
@@ -3,24 +3,30 @@ 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";
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
Volume2,
|
Volume2,
|
||||||
FileVolume,
|
FileVolume,
|
||||||
ChevronsUp,
|
ChevronsUp,
|
||||||
Palette,
|
Palette,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Download,
|
Download,
|
||||||
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";
|
||||||
@@ -35,7 +41,7 @@ export default function Settings() {
|
|||||||
const updateSettings = useStore((state) => state.updateSettings);
|
const updateSettings = useStore((state) => state.updateSettings);
|
||||||
const clearLocalSessions = useStore((state) => state.clearLocalSessions);
|
const clearLocalSessions = useStore((state) => state.clearLocalSessions);
|
||||||
const setDeviceId = useStore((state) => state.setDeviceId);
|
const setDeviceId = useStore((state) => state.setDeviceId);
|
||||||
|
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -58,45 +64,59 @@ export default function Settings() {
|
|||||||
sessions: localSessions,
|
sessions: localSessions,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
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');
|
|
||||||
linkElement.setAttribute('href', dataUri);
|
const linkElement = document.createElement("a");
|
||||||
linkElement.setAttribute('download', exportFileDefaultName);
|
linkElement.setAttribute("href", dataUri);
|
||||||
|
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(","),
|
||||||
|
),
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
].join("\n");
|
||||||
const link = document.createElement('a');
|
|
||||||
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||||
|
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,42 +155,60 @@ 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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<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 />
|
||||||
@@ -185,16 +220,20 @@ export default function Settings() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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,21 +252,27 @@ 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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<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 />
|
||||||
@@ -238,7 +283,7 @@ export default function Settings() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="volume">Volume</Label>
|
<Label htmlFor="volume">Volume</Label>
|
||||||
<div className="flex items-center space-x-3 mt-2">
|
<div className="flex items-center space-x-3 mt-2">
|
||||||
@@ -256,7 +301,7 @@ export default function Settings() {
|
|||||||
<ChevronsUp className="text-muted-foreground" />
|
<ChevronsUp className="text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -282,41 +327,81 @@ 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>
|
||||||
|
|
||||||
<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,41 +434,60 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
|
@@ -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
3830
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
75
package.json
75
package.json
@@ -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",
|
||||||
|
76
replit.md
76
replit.md
@@ -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
|
|
@@ -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",
|
||||||
|
@@ -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,
|
||||||
|
@@ -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,
|
||||||
|
Reference in New Issue
Block a user