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
|
||||
server/public
|
||||
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>
|
||||
<div id="root"></div>
|
||||
<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>
|
||||
</html>
|
||||
</html>
|
||||
|
@@ -7,12 +7,20 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "@/lib/theme";
|
||||
import { useStore } from "@/lib/store";
|
||||
import { getOrCreateDeviceId } from "@/lib/device";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { ShortcutIndicator } from "@/components/atoms/ShortcutIndicator";
|
||||
|
||||
import Home from "@/pages/Home";
|
||||
import History from "@/pages/History";
|
||||
import Settings from "@/pages/Settings";
|
||||
import NotFound from "@/pages/not-found";
|
||||
|
||||
// Separate component for keyboard shortcuts to prevent infinite loops
|
||||
function KeyboardShortcutsProvider() {
|
||||
useKeyboardShortcuts();
|
||||
return null;
|
||||
}
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
@@ -41,7 +49,9 @@ function App() {
|
||||
<ThemeProvider defaultTheme="light" storageKey="pomodorian-theme">
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<KeyboardShortcutsProvider />
|
||||
<Router />
|
||||
<ShortcutIndicator />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
@@ -5,9 +5,9 @@ interface ProgressRingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProgressRing({
|
||||
progress,
|
||||
size = 256,
|
||||
export function ProgressRing({
|
||||
progress,
|
||||
size = 256,
|
||||
strokeWidth = 8,
|
||||
className = ""
|
||||
}: ProgressRingProps) {
|
||||
@@ -17,10 +17,10 @@ export function ProgressRing({
|
||||
const strokeDashoffset = circumference * (1 - progress);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`transform -rotate-90 ${className}`}
|
||||
width={size}
|
||||
height={size}
|
||||
<svg
|
||||
className={`transform -rotate-90 ${className}`}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
data-testid="progress-ring"
|
||||
>
|
||||
@@ -34,7 +34,7 @@ export function ProgressRing({
|
||||
fill="none"
|
||||
className="text-muted opacity-20"
|
||||
/>
|
||||
|
||||
|
||||
{/* Progress Circle */}
|
||||
<circle
|
||||
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
|
||||
key={preset.name}
|
||||
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' : ''
|
||||
}`}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
data-testid={`button-preset-${preset.name.toLowerCase()}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<div className={`font-medium ${
|
||||
<div className="flex items-center justify-between w-full h-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium break-words ${
|
||||
selectedPreset === preset.name ? 'text-primary' : 'group-hover:text-primary'
|
||||
} transition-colors`}>
|
||||
{preset.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground break-words leading-relaxed">
|
||||
{preset.description}
|
||||
</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
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-muted-foreground'
|
||||
|
@@ -65,18 +65,18 @@ export function SuggestionChips({ onSuggestionSelect, className = "" }: Suggesti
|
||||
<Button
|
||||
key={index}
|
||||
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)}
|
||||
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">
|
||||
<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'}
|
||||
</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>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground break-words leading-relaxed">
|
||||
{suggestion.reason}
|
||||
</p>
|
||||
</div>
|
||||
|
@@ -10,7 +10,11 @@ interface SidebarProps {
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ className = "", isMobile = false, onNavigate }: SidebarProps) {
|
||||
export function Sidebar({
|
||||
className = "",
|
||||
isMobile = false,
|
||||
onNavigate,
|
||||
}: SidebarProps) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [location] = useLocation();
|
||||
const localSessions = useStore((state) => state.localSessions);
|
||||
@@ -18,23 +22,34 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
||||
// Calculate today's stats
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const todaySessions = localSessions.filter(session => {
|
||||
|
||||
const todaySessions = localSessions.filter((session) => {
|
||||
const sessionDate = new Date(session.startedAt);
|
||||
sessionDate.setHours(0, 0, 0, 0);
|
||||
return sessionDate.getTime() === today.getTime();
|
||||
});
|
||||
|
||||
const todayFocusSessions = todaySessions.filter(s => s.type === 'focus');
|
||||
const completedToday = todayFocusSessions.filter(s => s.completed);
|
||||
const completionRate = todayFocusSessions.length > 0
|
||||
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
||||
: 0;
|
||||
const todayFocusSessions = todaySessions.filter((s) => s.type === "focus");
|
||||
const completedToday = todayFocusSessions.filter((s) => s.completed);
|
||||
const completionRate =
|
||||
todayFocusSessions.length > 0
|
||||
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
||||
: 0;
|
||||
|
||||
const navItems = [
|
||||
{ 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) => {
|
||||
@@ -44,9 +59,11 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`w-64 bg-card border-r border-border flex-shrink-0 sidebar-transition ${
|
||||
isMobile ? 'sidebar-mobile' : ''
|
||||
} ${className}`}>
|
||||
<aside
|
||||
className={`w-64 bg-card border-r border-border flex-shrink-0 sidebar-transition ${
|
||||
isMobile ? "sidebar-mobile" : ""
|
||||
} ${className}`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Logo/Brand */}
|
||||
<div className="p-6 border-b border-border">
|
||||
@@ -63,15 +80,15 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
|
||||
|
||||
return (
|
||||
<Link key={item.path} href={item.path}>
|
||||
<Button
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={`w-full justify-start space-x-3 ${
|
||||
active
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={onNavigate}
|
||||
data-testid={item.testId}
|
||||
@@ -96,7 +113,10 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<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}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -105,24 +125,50 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
||||
{/* Theme Toggle */}
|
||||
<Button
|
||||
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}
|
||||
data-testid="button-theme-toggle"
|
||||
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
>
|
||||
<span className="flex items-center space-x-3">
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
{theme === "dark" ? (
|
||||
<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>
|
||||
<div className={`w-10 h-6 rounded-full relative transition-colors ${
|
||||
theme === 'dark' ? 'bg-primary' : 'bg-secondary'
|
||||
}`}>
|
||||
<div className={`w-4 h-4 bg-white rounded-full absolute top-1 transition-transform ${
|
||||
theme === 'dark' ? 'translate-x-5' : 'translate-x-1'
|
||||
}`} />
|
||||
<div
|
||||
className={`w-11 h-6 rounded-full relative transition-all duration-200 ease-in-out ${
|
||||
theme === "dark"
|
||||
? "bg-primary shadow-inner"
|
||||
: "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>
|
||||
</Button>
|
||||
</div>
|
||||
|
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
))
|
||||
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
"text-2xl font-semibold leading-none tracking-tight break-words",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -50,7 +50,7 @@ const CardDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-sm text-muted-foreground break-words", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ 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"
|
||||
|
||||
@@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
))
|
||||
|
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 {
|
||||
transition: stroke-dasharray 0.3s ease-in-out;
|
||||
}
|
||||
@@ -98,3 +121,8 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
div,
|
||||
p {
|
||||
text-wrap: auto !important;
|
||||
}
|
||||
|
@@ -9,15 +9,16 @@ export class AudioManager {
|
||||
|
||||
private async initializeAudioContext() {
|
||||
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.connect(this.audioContext.destination);
|
||||
|
||||
|
||||
// Load default sounds
|
||||
await this.loadSound('chime', '/src/assets/sounds/chime.mp3');
|
||||
await this.loadSound('beep', '/src/assets/sounds/beep.mp3');
|
||||
await this.loadSound("chime", "/src/assets/sounds/chime.mp3");
|
||||
await this.loadSound("beep", "/src/assets/sounds/beep.mp3");
|
||||
} 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) {
|
||||
if (!this.audioContext || !this.gainNode) {
|
||||
console.warn('Audio context not available');
|
||||
console.warn("Audio context not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -73,18 +74,21 @@ export class AudioManager {
|
||||
try {
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
|
||||
this.gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
|
||||
source.connect(this.gainNode);
|
||||
source.start();
|
||||
} catch (error) {
|
||||
console.warn('Failed to play sound:', error);
|
||||
console.warn("Failed to play sound:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume: number) {
|
||||
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;
|
||||
|
||||
try {
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
if (this.audioContext.state === "suspended") {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to request audio permission:', error);
|
||||
console.warn("Failed to request audio permission:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@@ -1,22 +1,22 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function generateDeviceId(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
export function getOrCreateDeviceId(): string {
|
||||
const stored = localStorage.getItem('pomodorian-device-id');
|
||||
const stored = localStorage.getItem("pomodorian-device-id");
|
||||
if (stored) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
|
||||
const newId = generateDeviceId();
|
||||
localStorage.setItem('pomodorian-device-id', newId);
|
||||
localStorage.setItem("pomodorian-device-id", newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
export function regenerateDeviceId(): string {
|
||||
const newId = generateDeviceId();
|
||||
localStorage.setItem('pomodorian-device-id', newId);
|
||||
localStorage.setItem("pomodorian-device-id", newId);
|
||||
return newId;
|
||||
}
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Session } from '@shared/schema';
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { Session } from "@shared/schema";
|
||||
|
||||
export interface TimerState {
|
||||
isRunning: boolean;
|
||||
currentTime: number; // seconds
|
||||
totalTime: number; // seconds
|
||||
phase: 'focus' | 'break';
|
||||
phase: "focus" | "break";
|
||||
sessionCount: number;
|
||||
interruptions: number;
|
||||
startedAt?: Date;
|
||||
@@ -21,29 +21,43 @@ export interface Settings {
|
||||
soundEnabled: boolean;
|
||||
soundVolume: number; // 0-1
|
||||
soundType: string;
|
||||
theme: 'light' | 'dark';
|
||||
theme: "light" | "dark";
|
||||
animationsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ShortcutsState {
|
||||
lastShortcut: string | null;
|
||||
isVisible: boolean;
|
||||
shortcutsEnabled: boolean;
|
||||
}
|
||||
|
||||
interface Store {
|
||||
// Timer state
|
||||
timer: TimerState;
|
||||
|
||||
|
||||
// Settings
|
||||
settings: Settings;
|
||||
|
||||
|
||||
// Local session history
|
||||
localSessions: Session[];
|
||||
|
||||
|
||||
// Device ID
|
||||
deviceId: string;
|
||||
|
||||
|
||||
// Shortcuts state
|
||||
shortcuts: ShortcutsState;
|
||||
|
||||
// Actions
|
||||
setTimer: (timer: Partial<TimerState>) => void;
|
||||
updateSettings: (settings: Partial<Settings>) => void;
|
||||
addLocalSession: (session: Session) => void;
|
||||
clearLocalSessions: () => void;
|
||||
setDeviceId: (deviceId: string) => void;
|
||||
|
||||
// Shortcuts actions
|
||||
showShortcut: (shortcut: string) => void;
|
||||
hideShortcut: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -54,8 +68,8 @@ const defaultSettings: Settings = {
|
||||
autoStartBreaks: true,
|
||||
soundEnabled: true,
|
||||
soundVolume: 0.7,
|
||||
soundType: 'chime',
|
||||
theme: 'light',
|
||||
soundType: "chime",
|
||||
theme: "light",
|
||||
animationsEnabled: true,
|
||||
};
|
||||
|
||||
@@ -63,18 +77,25 @@ const defaultTimer: TimerState = {
|
||||
isRunning: false,
|
||||
currentTime: 25 * 60,
|
||||
totalTime: 25 * 60,
|
||||
phase: 'focus',
|
||||
phase: "focus",
|
||||
sessionCount: 0,
|
||||
interruptions: 0,
|
||||
};
|
||||
|
||||
const defaultShortcuts: ShortcutsState = {
|
||||
lastShortcut: null,
|
||||
isVisible: false,
|
||||
shortcutsEnabled: true,
|
||||
};
|
||||
|
||||
export const useStore = create<Store>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
timer: defaultTimer,
|
||||
settings: defaultSettings,
|
||||
localSessions: [],
|
||||
deviceId: '',
|
||||
deviceId: "",
|
||||
shortcuts: defaultShortcuts,
|
||||
|
||||
setTimer: (timerUpdate) =>
|
||||
set((state) => ({
|
||||
@@ -100,14 +121,40 @@ export const useStore = create<Store>()(
|
||||
set(() => ({
|
||||
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) => ({
|
||||
settings: state.settings,
|
||||
localSessions: state.localSessions,
|
||||
deviceId: state.deviceId,
|
||||
shortcuts: state.shortcuts,
|
||||
}),
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@@ -29,7 +29,7 @@ export function ThemeProvider({
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
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 "./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 { HistoryChart } from "@/components/organisms/HistoryChart";
|
||||
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 { Download, Clock, CheckCircle, Brain, Flame } from "lucide-react";
|
||||
import { useStore } from "@/lib/store";
|
||||
@@ -14,21 +20,24 @@ export default function History() {
|
||||
const getSessionsInRange = (days: number) => {
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
return localSessions.filter(session =>
|
||||
new Date(session.startedAt) >= startDate
|
||||
return localSessions.filter(
|
||||
(session) => new Date(session.startedAt) >= startDate,
|
||||
);
|
||||
};
|
||||
|
||||
const calculateStats = (sessions: any[]) => {
|
||||
const focusSessions = sessions.filter(s => s.type === 'focus');
|
||||
const completed = focusSessions.filter(s => s.completed);
|
||||
|
||||
const focusSessions = sessions.filter((s) => s.type === "focus");
|
||||
const completed = focusSessions.filter((s) => s.completed);
|
||||
|
||||
return {
|
||||
totalSessions: focusSessions.length,
|
||||
completionRate: focusSessions.length > 0 ? completed.length / focusSessions.length : 0,
|
||||
avgFocusTime: completed.length > 0
|
||||
? completed.reduce((sum, s) => sum + s.intendedMinutes, 0) / completed.length
|
||||
: 0,
|
||||
completionRate:
|
||||
focusSessions.length > 0 ? completed.length / focusSessions.length : 0,
|
||||
avgFocusTime:
|
||||
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 today = new Date();
|
||||
let streak = 0;
|
||||
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
const dayEnd = new Date(date);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const daySessions = localSessions.filter(session => {
|
||||
|
||||
const daySessions = localSessions.filter((session) => {
|
||||
const sessionDate = new Date(session.startedAt);
|
||||
return sessionDate >= date && sessionDate <= dayEnd &&
|
||||
session.type === 'focus' && session.completed;
|
||||
return (
|
||||
sessionDate >= date &&
|
||||
sessionDate <= dayEnd &&
|
||||
session.type === "focus" &&
|
||||
session.completed
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
if (daySessions.length > 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return streak;
|
||||
};
|
||||
|
||||
@@ -68,27 +82,36 @@ export default function History() {
|
||||
|
||||
const handleExport = () => {
|
||||
const sessionsToExport = getSessionsInRange(rangeInDays);
|
||||
|
||||
|
||||
// CSV Export
|
||||
const headers = ['Date', 'Type', 'Intended Minutes', 'Actual Seconds', 'Completed', 'Interruptions'];
|
||||
const headers = [
|
||||
"Date",
|
||||
"Type",
|
||||
"Intended Minutes",
|
||||
"Actual Seconds",
|
||||
"Completed",
|
||||
"Interruptions",
|
||||
];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...sessionsToExport.map(session => [
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
session.type,
|
||||
session.intendedMinutes,
|
||||
session.actualSeconds,
|
||||
session.completed,
|
||||
session.interruptions
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
headers.join(","),
|
||||
...sessionsToExport.map((session) =>
|
||||
[
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
session.type,
|
||||
session.intendedMinutes,
|
||||
session.actualSeconds,
|
||||
session.completed,
|
||||
session.interruptions,
|
||||
].join(","),
|
||||
),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `pomodorian-sessions-${timeRange}d.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `pomodorian-sessions-${timeRange}d.csv`);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -97,35 +120,37 @@ export default function History() {
|
||||
const formatDuration = (seconds: number) => {
|
||||
const minutes = Math.floor(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 date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
|
||||
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()) {
|
||||
return `Yesterday, ${date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
|
||||
return `Yesterday, ${date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
title="Session History"
|
||||
<MainLayout
|
||||
title="Session History"
|
||||
subtitle="Track your productivity trends and insights"
|
||||
>
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-8 fade-in">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Session History</h2>
|
||||
<p className="text-muted-foreground">Track your productivity trends and insights</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-2xl font-bold break-words">Session History</h2>
|
||||
<p className="text-muted-foreground break-words">
|
||||
Track your productivity trends and insights
|
||||
</p>
|
||||
</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}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
@@ -152,15 +177,20 @@ export default function History() {
|
||||
<Clock className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Sessions</p>
|
||||
<p className="text-2xl font-bold" data-testid="text-total-sessions">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total Sessions
|
||||
</p>
|
||||
<p
|
||||
className="text-2xl font-bold"
|
||||
data-testid="text-total-sessions"
|
||||
>
|
||||
{stats.totalSessions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
@@ -168,15 +198,20 @@ export default function History() {
|
||||
<CheckCircle className="text-chart-2" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Completion Rate</p>
|
||||
<p className="text-2xl font-bold" data-testid="text-completion-rate">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Completion Rate
|
||||
</p>
|
||||
<p
|
||||
className="text-2xl font-bold"
|
||||
data-testid="text-completion-rate"
|
||||
>
|
||||
{Math.round(stats.completionRate * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
@@ -184,15 +219,20 @@ export default function History() {
|
||||
<Brain className="text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Avg Focus Time</p>
|
||||
<p className="text-2xl font-bold" data-testid="text-avg-focus">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Avg Focus Time
|
||||
</p>
|
||||
<p
|
||||
className="text-2xl font-bold"
|
||||
data-testid="text-avg-focus"
|
||||
>
|
||||
{Math.round(stats.avgFocusTime)}m
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
@@ -200,8 +240,13 @@ export default function History() {
|
||||
<Flame className="text-chart-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Current Streak</p>
|
||||
<p className="text-2xl font-bold" data-testid="text-current-streak">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current Streak
|
||||
</p>
|
||||
<p
|
||||
className="text-2xl font-bold"
|
||||
data-testid="text-current-streak"
|
||||
>
|
||||
{currentStreak} days
|
||||
</p>
|
||||
</div>
|
||||
@@ -247,25 +292,32 @@ export default function History() {
|
||||
{formatDateTime(session.startedAt.toString())}
|
||||
</td>
|
||||
<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 ${
|
||||
session.type === 'focus'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-accent/10 text-accent'
|
||||
}`}>
|
||||
{session.type === 'focus' ? 'Focus' : 'Break'}
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
session.type === "focus"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-accent/10 text-accent"
|
||||
}`}
|
||||
>
|
||||
{session.type === "focus" ? "Focus" : "Break"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{session.intendedMinutes}:00 / {formatDuration(session.actualSeconds)}
|
||||
{session.intendedMinutes}:00 /{" "}
|
||||
{formatDuration(session.actualSeconds)}
|
||||
</td>
|
||||
<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 ${
|
||||
session.completed
|
||||
? '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'}
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
session.completed
|
||||
? "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"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
@@ -275,7 +327,10 @@ export default function History() {
|
||||
))}
|
||||
{sessionsInRange.length === 0 && (
|
||||
<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.
|
||||
</td>
|
||||
</tr>
|
||||
|
@@ -39,12 +39,12 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleDurationChange = (focus: number, breakDuration: number) => {
|
||||
updateSettings({
|
||||
focusDuration: focus,
|
||||
shortBreakDuration: breakDuration
|
||||
updateSettings({
|
||||
focusDuration: focus,
|
||||
shortBreakDuration: breakDuration,
|
||||
});
|
||||
|
||||
if (timer.phase === 'focus') {
|
||||
|
||||
if (timer.phase === "focus") {
|
||||
const durationSeconds = focus * 60;
|
||||
setTimer({
|
||||
currentTime: durationSeconds,
|
||||
@@ -55,32 +55,37 @@ export default function Home() {
|
||||
|
||||
const handleExportData = () => {
|
||||
const dataStr = JSON.stringify(localSessions, null, 2);
|
||||
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = `pomodorian-sessions-${new Date().toISOString().split('T')[0]}.json`;
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
const dataUri =
|
||||
"data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = `pomodorian-sessions-${new Date().toISOString().split("T")[0]}.json`;
|
||||
|
||||
const linkElement = document.createElement("a");
|
||||
linkElement.setAttribute("href", dataUri);
|
||||
linkElement.setAttribute("download", exportFileDefaultName);
|
||||
linkElement.click();
|
||||
};
|
||||
|
||||
// Calculate today's progress
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const todaySessions = localSessions.filter(session => {
|
||||
|
||||
const todaySessions = localSessions.filter((session) => {
|
||||
const sessionDate = new Date(session.startedAt);
|
||||
sessionDate.setHours(0, 0, 0, 0);
|
||||
return sessionDate.getTime() === today.getTime();
|
||||
});
|
||||
|
||||
const todayFocusSessions = todaySessions.filter(s => s.type === 'focus');
|
||||
const completedToday = todayFocusSessions.filter(s => s.completed);
|
||||
const todayFocusTime = completedToday.reduce((sum, s) => sum + s.intendedMinutes, 0);
|
||||
const completionRate = todayFocusSessions.length > 0
|
||||
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
||||
: 0;
|
||||
const todayFocusSessions = todaySessions.filter((s) => s.type === "focus");
|
||||
const completedToday = todayFocusSessions.filter((s) => s.completed);
|
||||
const todayFocusTime = completedToday.reduce(
|
||||
(sum, s) => sum + s.intendedMinutes,
|
||||
0,
|
||||
);
|
||||
const completionRate =
|
||||
todayFocusSessions.length > 0
|
||||
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
||||
: 0;
|
||||
|
||||
const formatFocusTime = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
@@ -92,8 +97,8 @@ export default function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
title="Focus Timer"
|
||||
<MainLayout
|
||||
title="Focus Timer"
|
||||
subtitle="Stay focused with the Pomodoro Technique"
|
||||
>
|
||||
<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">
|
||||
{/* Timer Circle */}
|
||||
<div className="lg:col-span-2">
|
||||
{ /* Pomodoro Timer Component */ }
|
||||
<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>
|
||||
|
||||
{/* Duration Selector & Settings */}
|
||||
@@ -124,64 +179,36 @@ export default function Home() {
|
||||
<CardContent className="space-y-3">
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<Volume2 className="text-accent" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Sound Settings</div>
|
||||
<div className="text-sm text-muted-foreground">Adjust notification sounds</div>
|
||||
<Volume2 className="text-accent flex-shrink-0" />
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="font-medium break-words">
|
||||
Sound Settings
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground break-words leading-relaxed">
|
||||
Adjust notification sounds
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
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}
|
||||
data-testid="button-export-data"
|
||||
>
|
||||
<Download className="text-chart-2" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Export Data</div>
|
||||
<div className="text-sm text-muted-foreground">Download session history</div>
|
||||
<Download className="text-chart-2 flex-shrink-0" />
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="font-medium break-words">Export Data</div>
|
||||
<div className="text-sm text-muted-foreground break-words leading-relaxed">
|
||||
Download session history
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
@@ -3,24 +3,30 @@ import { MainLayout } from "@/components/templates/MainLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 { Slider } from "@/components/ui/slider";
|
||||
import { Button } from "@/components/atoms/Button";
|
||||
import {
|
||||
Clock,
|
||||
Volume2,
|
||||
FileVolume,
|
||||
ChevronsUp,
|
||||
Palette,
|
||||
ShieldAlert,
|
||||
Sun,
|
||||
Moon,
|
||||
Download,
|
||||
FileText,
|
||||
import {
|
||||
Clock,
|
||||
Volume2,
|
||||
FileVolume,
|
||||
ChevronsUp,
|
||||
Palette,
|
||||
ShieldAlert,
|
||||
Sun,
|
||||
Moon,
|
||||
Download,
|
||||
FileText,
|
||||
Trash2,
|
||||
Play,
|
||||
Keyboard
|
||||
Keyboard,
|
||||
} from "lucide-react";
|
||||
import { useStore } from "@/lib/store";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
@@ -35,7 +41,7 @@ export default function Settings() {
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
const clearLocalSessions = useStore((state) => state.clearLocalSessions);
|
||||
const setDeviceId = useStore((state) => state.setDeviceId);
|
||||
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -58,45 +64,59 @@ export default function Settings() {
|
||||
sessions: localSessions,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
||||
const dataStr = JSON.stringify(data, null, 2);
|
||||
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = `pomodorian-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
const dataUri =
|
||||
"data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = `pomodorian-export-${new Date().toISOString().split("T")[0]}.json`;
|
||||
|
||||
const linkElement = document.createElement("a");
|
||||
linkElement.setAttribute("href", dataUri);
|
||||
linkElement.setAttribute("download", exportFileDefaultName);
|
||||
linkElement.click();
|
||||
};
|
||||
|
||||
const handleExportCSV = () => {
|
||||
const headers = ['Date', 'Type', 'Intended Minutes', 'Actual Seconds', 'Completed', 'Interruptions'];
|
||||
const headers = [
|
||||
"Date",
|
||||
"Type",
|
||||
"Intended Minutes",
|
||||
"Actual Seconds",
|
||||
"Completed",
|
||||
"Interruptions",
|
||||
];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...localSessions.map(session => [
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
session.type,
|
||||
session.intendedMinutes,
|
||||
session.actualSeconds,
|
||||
session.completed,
|
||||
session.interruptions
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
headers.join(","),
|
||||
...localSessions.map((session) =>
|
||||
[
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
session.type,
|
||||
session.intendedMinutes,
|
||||
session.actualSeconds,
|
||||
session.completed,
|
||||
session.interruptions,
|
||||
].join(","),
|
||||
),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `pomodorian-sessions.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `pomodorian-sessions.csv`);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
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();
|
||||
toast({
|
||||
title: "Data cleared",
|
||||
@@ -115,10 +135,7 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
title="Settings"
|
||||
subtitle="Customize your Pomodoro experience"
|
||||
>
|
||||
<MainLayout title="Settings" subtitle="Customize your Pomodoro experience">
|
||||
<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">
|
||||
{/* Timer Settings */}
|
||||
@@ -138,42 +155,60 @@ export default function Settings() {
|
||||
value={settings.focusDuration}
|
||||
min="1"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label htmlFor="short-break-duration">Short Break Duration (minutes)</Label>
|
||||
<Label htmlFor="short-break-duration">
|
||||
Short Break Duration (minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="short-break-duration"
|
||||
type="number"
|
||||
value={settings.shortBreakDuration}
|
||||
min="1"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label htmlFor="long-break-duration">Long Break Duration (minutes)</Label>
|
||||
<Label htmlFor="long-break-duration">
|
||||
Long Break Duration (minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="long-break-duration"
|
||||
type="number"
|
||||
value={settings.longBreakDuration}
|
||||
min="5"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label htmlFor="long-break-interval">Long Break Interval</Label>
|
||||
<Select
|
||||
value={settings.longBreakInterval.toString()}
|
||||
onValueChange={(value) => updateSettings({ longBreakInterval: parseInt(value) })}
|
||||
<Select
|
||||
value={settings.longBreakInterval.toString()}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ longBreakInterval: parseInt(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid="select-long-break-interval">
|
||||
<SelectValue />
|
||||
@@ -185,16 +220,20 @@ export default function Settings() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<Switch
|
||||
id="auto-start-breaks"
|
||||
checked={settings.autoStartBreaks}
|
||||
onCheckedChange={(checked) => updateSettings({ autoStartBreaks: checked })}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings({ autoStartBreaks: checked })
|
||||
}
|
||||
data-testid="switch-auto-start-breaks"
|
||||
/>
|
||||
</div>
|
||||
@@ -213,21 +252,27 @@ export default function Settings() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<Switch
|
||||
id="sound-enabled"
|
||||
checked={settings.soundEnabled}
|
||||
onCheckedChange={(checked) => updateSettings({ soundEnabled: checked })}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings({ soundEnabled: checked })
|
||||
}
|
||||
data-testid="switch-sound-enabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sound-type">Notification Sound</Label>
|
||||
<Select
|
||||
value={settings.soundType}
|
||||
onValueChange={(value) => updateSettings({ soundType: value })}
|
||||
<Select
|
||||
value={settings.soundType}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ soundType: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid="select-sound-type">
|
||||
<SelectValue />
|
||||
@@ -238,7 +283,7 @@ export default function Settings() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label htmlFor="volume">Volume</Label>
|
||||
<div className="flex items-center space-x-3 mt-2">
|
||||
@@ -256,7 +301,7 @@ export default function Settings() {
|
||||
<ChevronsUp className="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
@@ -282,41 +327,81 @@ export default function Settings() {
|
||||
<Label>Theme</Label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||
<Button
|
||||
variant={theme === 'light' ? 'default' : 'outline'}
|
||||
className="h-auto p-3 text-left"
|
||||
onClick={() => setTheme('light')}
|
||||
variant={theme === "light" ? "default" : "outline"}
|
||||
className="h-auto p-3 text-left min-h-[80px] hover:bg-[#f1f5f9] transition-colors group"
|
||||
onClick={() => setTheme("light")}
|
||||
data-testid="button-theme-light"
|
||||
>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Sun className="text-primary" />
|
||||
<span className="font-medium">Light</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Sun
|
||||
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 className="text-xs text-muted-foreground">Clean and bright</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === 'dark' ? 'default' : 'outline'}
|
||||
className="h-auto p-3 text-left"
|
||||
onClick={() => setTheme('dark')}
|
||||
variant={theme === "dark" ? "default" : "outline"}
|
||||
className="h-auto p-3 text-left min-h-[80px] hover:bg-[#f1f5f9] transition-colors group"
|
||||
onClick={() => setTheme("dark")}
|
||||
data-testid="button-theme-dark"
|
||||
>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Moon />
|
||||
<span className="font-medium">Dark</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Moon
|
||||
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 className="text-xs text-muted-foreground">Easy on the eyes</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<Switch
|
||||
id="animations"
|
||||
checked={settings.animationsEnabled}
|
||||
onCheckedChange={(checked) => updateSettings({ animationsEnabled: checked })}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings({ animationsEnabled: checked })
|
||||
}
|
||||
data-testid="switch-animations"
|
||||
/>
|
||||
</div>
|
||||
@@ -334,9 +419,14 @@ export default function Settings() {
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<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">
|
||||
<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}
|
||||
</code>
|
||||
<Button
|
||||
@@ -344,41 +434,60 @@ export default function Settings() {
|
||||
size="sm"
|
||||
onClick={handleRegenerateDeviceId}
|
||||
data-testid="button-regenerate-device-id"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
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}
|
||||
data-testid="button-export-json"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export All Data (JSON)
|
||||
<div className="flex items-center space-x-2">
|
||||
<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
|
||||
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}
|
||||
data-testid="button-export-csv"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Export Sessions (CSV)
|
||||
<div className="flex items-center space-x-2">
|
||||
<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
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
className="w-full h-auto p-3 text-left"
|
||||
onClick={handleClearData}
|
||||
data-testid="button-clear-data"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear All Data
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -394,22 +503,47 @@ export default function Settings() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span>Start/Pause Timer</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">Space</kbd>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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 className="flex items-center justify-between py-2">
|
||||
<span>Reset Timer</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">R</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span>Skip Break</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">S</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span>Toggle Theme</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">Ctrl + D</kbd>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These shortcuts are active throughout the app. Try them now!
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="break-words">Start/Pause Timer</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono flex-shrink-0 ml-2">
|
||||
Space
|
||||
</kbd>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
|
@@ -8,7 +8,9 @@ export default function NotFound() {
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex mb-4 gap-2">
|
||||
<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>
|
||||
|
||||
<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": {
|
||||
"dev": "NODE_ENV=development tsx server/index.ts",
|
||||
"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",
|
||||
"check": "tsc",
|
||||
"db:push": "drizzle-kit push"
|
||||
@@ -14,33 +15,33 @@
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@neondatabase/serverless": "^0.10.4",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.5",
|
||||
"@radix-ui/react-collapsible": "^1.1.4",
|
||||
"@radix-ui/react-context-menu": "^2.2.7",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-hover-card": "^1.1.7",
|
||||
"@radix-ui/react-label": "^2.1.3",
|
||||
"@radix-ui/react-menubar": "^1.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.6",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.3",
|
||||
"@radix-ui/react-radio-group": "^1.2.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slider": "^1.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.4",
|
||||
"@radix-ui/react-tabs": "^1.1.4",
|
||||
"@radix-ui/react-toast": "^1.2.7",
|
||||
"@radix-ui/react-toggle": "^1.1.3",
|
||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -48,12 +49,13 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.39.1",
|
||||
"drizzle-zod": "^0.7.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"framer-motion": "^11.13.1",
|
||||
"framer-motion": "^12.23.12",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"memorystore": "^1.6.7",
|
||||
@@ -61,13 +63,13 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.9.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.15.2",
|
||||
"recharts": "^3.1.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
@@ -80,20 +82,19 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@replit/vite-plugin-cartographer": "^0.3.0",
|
||||
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"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 { setupVite, serveStatic, log } from "./vite";
|
||||
|
||||
// Load environment variables from .env file
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
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
|
||||
// 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.
|
||||
// It is the only port that is not firewalled.
|
||||
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({
|
||||
port,
|
||||
host: "0.0.0.0",
|
||||
|
@@ -8,7 +8,8 @@
|
||||
"module": "ESNext",
|
||||
"strict": true,
|
||||
"lib": ["esnext", "dom", "dom.iterable"],
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
|
@@ -1,20 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
runtimeErrorOverlay(),
|
||||
...(process.env.NODE_ENV !== "production" &&
|
||||
process.env.REPL_ID !== undefined
|
||||
? [
|
||||
await import("@replit/vite-plugin-cartographer").then((m) =>
|
||||
m.cartographer(),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
react({
|
||||
jsxImportSource: "react",
|
||||
jsxRuntime: "automatic",
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -23,7 +16,8 @@ export default defineConfig({
|
||||
"@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: {
|
||||
outDir: path.resolve(import.meta.dirname, "dist/public"),
|
||||
emptyOutDir: true,
|
||||
|
Reference in New Issue
Block a user