Compare commits

...

10 Commits

Author SHA1 Message Date
0bb29c6a01
add compose file for serial printer 2025-06-18 22:07:43 -05:00
273ad95fa0
update and dedupe lockfiles 2025-06-18 21:44:10 -05:00
599ed993d3
Add Docker support for client and server with Ubuntu-based images, build script, docker-compose, and comprehensive documentation 2025-06-18 21:43:34 -05:00
02d4bae997
improve printer format, reduce some logs 2025-06-18 20:31:10 -05:00
7d680e469f
switch printing to command pattern 2025-06-17 21:26:28 -05:00
8f3c8ba80e
Fix mobile layout: recent and frequent task groups now display correctly - Fixed isGroupWithTasks function to accept both string and number IDs - Virtual groups use negative number IDs (-1, -2) which were being rejected - Mobile layout now properly shows Recent Tasks and Frequent Tasks groups 2025-06-17 20:41:02 -05:00
85ebe7b8d5
Fix screensaver animation blinking issue - Remove frequent screen clearing that caused black frames every few seconds - Keep only the 3-minute clear cycle for burn-in protection - Ensure smooth animation loop by scheduling next frame before any clearing - Eliminate brief black flashes that occurred at animation cycle boundaries - Maintain continuous visual flow without interruption 2025-06-17 20:36:41 -05:00
2402070ef1
Improve night fade to completely turn off display - Change from gradual fade to complete blackout (0 brightness) during night hours - 15-minute fade out from midnight to 12:15am - Complete blackout from 12:15am to 5:45am (5.5 hours) - 15-minute fade in from 5:45am to 6:00am - Maximizes energy savings and minimizes light pollution for wall-mounted displays - Provides smooth transitions to avoid jarring on/off behavior 2025-06-17 20:34:20 -05:00
54ffdc37f7
Add night fade and 24-hour clock to screensaver - Implement fade to black feature for midnight to 6am hours - Gradual fade in from midnight to 3am, then fade out until 6am - Add 24-hour clock display in center of screen with current time and date - Clock shows HH:MM:SS format with full date below - Semi-transparent black background behind clock for readability - Clock updates in real-time during screensaver animation - Night fade applies overlay to reduce brightness during sleeping hours 2025-06-17 20:31:32 -05:00
91fb01bebf
Fix screensaver functionality and add auto-activation - Fix immediate closing issue by adding 1-second delay before enabling interaction detection - Remove duplicate ApolloProvider from App.tsx to fix component hierarchy - Add useAutoScreensaver hook that tracks user activity and activates after 5 minutes - Implement automatic screensaver activation with manual override capability - Screensaver now properly appears and stays visible until user interaction - Add comprehensive activity tracking (mouse, keyboard, touch, scroll) - Reset auto-screensaver timer when manually closing screensaver 2025-06-17 20:28:13 -05:00
40 changed files with 3132 additions and 17134 deletions

253
DOCKER.md Normal file
View File

@ -0,0 +1,253 @@
# Docker Setup for Task Receipts
This document explains how to build and run the Task Receipts application using Docker.
## Prerequisites
- Docker installed and running
- Docker Compose (usually included with Docker Desktop)
## Quick Start
### Option 1: Using Docker Compose (Recommended)
1. **Build and run both services:**
```bash
docker-compose up --build
```
2. **Run in background:**
```bash
docker-compose up -d --build
```
3. **Stop services:**
```bash
docker-compose down
```
### Option 2: Using the Build Script
1. **Build both images:**
```bash
./build-docker.sh
```
2. **Build with specific version:**
```bash
./build-docker.sh v1.0.0
```
3. **Run containers individually:**
```bash
# Run server
docker run -p 4000:4000 task-receipts-server:latest
# Run client (in another terminal)
docker run -p 80:80 task-receipts-client:latest
```
## Services
### Server
- **Port:** 4000
- **Health Check:** GraphQL endpoint at `/graphql`
- **Database:** SQLite (persisted in `./server/data`)
- **Features:**
- GraphQL API
- YAML import/export
- Receipt printing
- Database management
### Client
- **Port:** 80
- **Web Server:** Nginx
- **Features:**
- React application
- Material-UI components
- Apollo Client for GraphQL
## Docker Images
### Client Image (`task-receipts-client`)
- **Base:** `nginx:stable` (Ubuntu-based)
- **Build:** Multi-stage build with Node.js 18 (Ubuntu-based)
- **Size:** Optimized for production
- **Features:**
- Static file serving
- Gzip compression
- Security headers
### Server Image (`task-receipts-server`)
- **Base:** `node:18-slim` (Ubuntu-based)
- **Build:** Multi-stage build with Node.js 18 (Ubuntu-based)
- **Size:** Optimized for production
- **Features:**
- Non-root user execution
- Health checks
- Graceful shutdown handling
- Signal handling with dumb-init
## Development
### Building Individual Images
```bash
# Build client only
cd client
docker build -t task-receipts-client:dev .
# Build server only
cd server
docker build -t task-receipts-server:dev .
```
### Development with Docker Compose
Create a `docker-compose.dev.yml` for development:
```yaml
version: '3.8'
services:
server:
build:
context: ./server
dockerfile: Dockerfile
ports:
- "4000:4000"
environment:
- NODE_ENV=development
volumes:
- ./server/src:/app/src
- ./shared:/app/shared
command: npm run dev
client:
build:
context: ./client
dockerfile: Dockerfile
ports:
- "5173:80"
volumes:
- ./client/src:/app/src
- ./shared:/app/shared
```
## Configuration
### Environment Variables
The server supports the following environment variables:
- `NODE_ENV`: Environment (production/development)
- `PORT`: Server port (default: 4000)
### Database Persistence
The server's SQLite database is persisted in the `./server/data` directory when using Docker Compose.
## Troubleshooting
### Common Issues
1. **Port conflicts:**
- Ensure ports 80 and 4000 are available
- Modify ports in `docker-compose.yml` if needed
2. **Build failures:**
- Check Docker is running
- Ensure all source files are present
- Check network connectivity for npm packages
3. **Client can't connect to server:**
- Verify server is running and healthy
- Check CORS configuration
- Ensure proper network connectivity
### Logs
```bash
# View all logs
docker-compose logs
# View specific service logs
docker-compose logs server
docker-compose logs client
# Follow logs in real-time
docker-compose logs -f
```
### Health Checks
```bash
# Check service health
docker-compose ps
# Check individual container health
docker inspect task-receipts-server | grep Health -A 10
```
## Production Deployment
### Using Docker Compose
1. **Build and deploy:**
```bash
docker-compose -f docker-compose.yml up -d --build
```
2. **Update services:**
```bash
docker-compose pull
docker-compose up -d
```
### Using Individual Containers
1. **Build images:**
```bash
./build-docker.sh v1.0.0
```
2. **Deploy with orchestration:**
```bash
# Example with Docker Swarm or Kubernetes
docker stack deploy -c docker-compose.yml task-receipts
```
## Security Considerations
- Images run as non-root users
- Ubuntu-based images for better security and performance
- No sensitive data in images
- Health checks for monitoring
- Graceful shutdown handling
## Performance Optimization
- Multi-stage builds reduce image size
- Nginx for static file serving
- Ubuntu-based images for better performance
- Production-only dependencies in final images
- Layer caching optimization
## Monitoring
### Health Checks
- Server: GraphQL endpoint availability
- Client: Nginx process status
### Metrics
- Container resource usage
- Application logs
- Health check status
## Support
For issues with the Docker setup:
1. Check the troubleshooting section
2. Review container logs
3. Verify Docker and Docker Compose versions
4. Ensure all prerequisites are met

166
PRINTER_SETUP.md Normal file
View File

@ -0,0 +1,166 @@
# Printer Setup for Task Receipts
This document explains how to set up the Task Receipts application with USB printer support using Docker.
## Prerequisites
- USB thermal printer (ESC/POS compatible)
- Docker and Docker Compose installed
- Linux host system (for USB device access)
## Quick Start
### 1. Run with Printer Support
```bash
# Build and start with printer support
docker-compose -f docker-compose.printer.yml up --build
# Or run in background
docker-compose -f docker-compose.printer.yml up -d --build
```
## Configuration Options
### Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `PRINTER_DRIVER` | Type of printer connection | `serial` |
### USB Device Mounting
The printer configuration includes several USB mounting options:
```yaml
volumes:
# Mount specific USB bus
- /dev/bus/usb:/dev/bus/usb:rw
devices:
# Mount USB devices specifically
- /dev/bus/usb:/dev/bus/usb
privileged: true # Required for USB access
```
## Troubleshooting
### Printer Not Found
1. **Check USB device listing:**
```bash
lsusb
```
2. **Verify device permissions:**
```bash
ls -la /dev/bus/usb/
```
3. **Check container logs:**
```bash
docker-compose -f docker-compose.printer.yml logs server
```
### Permission Issues
If you encounter permission issues:
1. **Add your user to the docker group:**
```bash
sudo usermod -aG docker $USER
```
2. **Restart Docker service:**
```bash
sudo systemctl restart docker
```
3. **Log out and back in** for group changes to take effect.
### Alternative: Run with sudo
If you still have issues, you can run with elevated privileges:
```bash
sudo docker-compose -f docker-compose.printer.yml up --build
```
## Common Printer Models
### ESC/POS Compatible Printers
Most thermal printers support ESC/POS commands. Common models include:
- **Star TSP100** series
- **Epson TM-T88** series
- **Citizen CT-S310** series
- **Generic thermal printers**
### Finding Your Printer's IDs
```bash
# Method 1: lsusb
lsusb
# Method 2: dmesg (after plugging in printer)
dmesg | tail -20
# Method 3: udevadm
udevadm info -a -n /dev/usb/lp0
```
## Security Considerations
⚠️ **Warning**: The printer configuration uses `privileged: true` which gives the container elevated privileges. This is necessary for USB device access but should be used carefully in production environments.
### Alternative Security Approaches
1. **Use specific device capabilities:**
```yaml
cap_add:
- SYS_RAWIO
```
2. **Mount only specific devices:**
```yaml
devices:
- /dev/usb/lp0:/dev/usb/lp0
```
3. **Use udev rules** to set proper permissions on the host.
## Testing Printer Connection
Once the container is running, you can test the printer connection:
1. **Check if printer is detected:**
```bash
docker exec -it task-receipts-server lsusb
```
2. **Test printer from the application:**
- Use the web interface to send a test print
- Check the server logs for printer-related messages
## Stopping the Services
```bash
# Stop all services
docker-compose -f docker-compose.printer.yml down
# Stop and remove volumes
docker-compose -f docker-compose.printer.yml down -v
```
## Support
For printer-specific issues:
1. Check the server logs for detailed error messages
2. Verify your printer is ESC/POS compatible
3. Ensure the USB IDs are correctly configured
4. Test the printer on the host system first
For Docker-related issues, refer to the main `DOCKER.md` documentation.

124
build-docker.sh Executable file
View File

@ -0,0 +1,124 @@
#!/bin/bash
# Build script for Task Receipts Docker images
# This script builds both the client and server Docker images
set -e # Exit on any error
# Configuration
PROJECT_NAME="task-receipts"
CLIENT_IMAGE_NAME="${PROJECT_NAME}-client"
SERVER_IMAGE_NAME="${PROJECT_NAME}-server"
VERSION=${1:-latest} # Use first argument as version, default to 'latest'
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to check if Docker is running
check_docker() {
if ! docker info > /dev/null 2>&1; then
print_error "Docker is not running or not accessible"
exit 1
fi
}
# Function to build client image
build_client() {
print_status "Building client image..."
# Build the image from root context with client Dockerfile
docker build -t "${CLIENT_IMAGE_NAME}:${VERSION}" -f client/Dockerfile .
if [ $? -eq 0 ]; then
print_success "Client image built successfully: ${CLIENT_IMAGE_NAME}:${VERSION}"
else
print_error "Failed to build client image"
exit 1
fi
}
# Function to build server image
build_server() {
print_status "Building server image..."
# Build the image from root context with server Dockerfile
docker build -t "${SERVER_IMAGE_NAME}:${VERSION}" -f server/Dockerfile .
if [ $? -eq 0 ]; then
print_success "Server image built successfully: ${SERVER_IMAGE_NAME}:${VERSION}"
else
print_error "Failed to build server image"
exit 1
fi
}
# Function to show usage
show_usage() {
echo "Usage: $0 [VERSION]"
echo ""
echo "Arguments:"
echo " VERSION Version tag for the Docker images (default: latest)"
echo ""
echo "Examples:"
echo " $0 # Build with 'latest' tag"
echo " $0 v1.0.0 # Build with 'v1.0.0' tag"
echo " $0 dev # Build with 'dev' tag"
}
# Main execution
main() {
print_status "Starting Docker build process for Task Receipts"
print_status "Version: ${VERSION}"
print_status "Client image: ${CLIENT_IMAGE_NAME}:${VERSION}"
print_status "Server image: ${SERVER_IMAGE_NAME}:${VERSION}"
echo ""
# Check if Docker is available
check_docker
# Build client image
build_client
# Build server image
build_server
echo ""
print_success "All images built successfully!"
echo ""
print_status "Built images:"
docker images | grep -E "(${CLIENT_IMAGE_NAME}|${SERVER_IMAGE_NAME})" || true
echo ""
print_status "To run the containers:"
echo " docker run -p 80:80 ${CLIENT_IMAGE_NAME}:${VERSION}"
echo " docker run -p 4000:4000 ${SERVER_IMAGE_NAME}:${VERSION}"
}
# Handle help argument
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
show_usage
exit 0
fi
# Run main function
main

44
client/.dockerignore Normal file
View File

@ -0,0 +1,44 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist/
build/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Logs
logs/
*.log
# Coverage
coverage/
# Test outputs
test-output/
# Docker
Dockerfile
.dockerignore

33
client/Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# Build stage
FROM node:18 AS builder
WORKDIR /app
# Copy root package files for workspaces
COPY package.json ./
COPY package-lock.json ./
# Copy client and shared code
COPY client/ ./client/
COPY shared/ ./shared/
# Install dependencies for client workspace
RUN npm ci --workspace=client
# Build the client
WORKDIR /app/client
RUN npm run build
# Production stage
FROM nginx:stable
COPY --from=builder /app/client/dist /usr/share/nginx/html
# Copy nginx configuration (optional - using default for now)
# COPY nginx.conf /etc/nginx/nginx.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

4286
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,10 @@
},
"dependencies": {
"@apollo/client": "^3.9.5",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.17.1",
"@mui/material": "^7.1.1",
"@mui/icons-material": "^7.1.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@task-receipts/shared": "file:../shared",
"graphql": "^16.8.1",
"react": "^18.2.0",
@ -23,8 +24,8 @@
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.34.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",

View File

@ -1,6 +1,4 @@
import { useCallback } from 'react';
import { ApolloProvider } from '@apollo/client';
import { client } from './graphql/client';
import { DesktopLayout } from './layouts/DesktopLayout';
import { MobileLayout } from './layouts/MobileLayout';
import { UserSelection } from './components/UserSelection';
@ -64,37 +62,35 @@ export function App() {
}
return (
<ApolloProvider client={client}>
<div className="app">
{deviceType === 'desktop' ? (
<DesktopLayout
groups={groups}
selectedGroup={selectedGroup}
selectedTask={selectedTask}
selectedStep={selectedStep}
onGroupSelect={setSelectedGroup}
onTaskSelect={setSelectedTask}
onStepSelect={setSelectedStep}
onPrintTask={handlePrintTask}
onPrintStep={handlePrintStep}
onAddNote={handleAddNote}
/>
) : (
<MobileLayout
groups={groups}
selectedGroup={selectedGroup}
selectedTask={selectedTask}
selectedStep={selectedStep}
onGroupSelect={setSelectedGroup}
onTaskSelect={setSelectedTask}
onStepSelect={setSelectedStep}
onBack={handleBack}
onPrintTask={handlePrintTask}
onPrintStep={handlePrintStep}
onAddNote={handleAddNote}
/>
)}
</div>
</ApolloProvider>
<div className="app">
{deviceType === 'desktop' ? (
<DesktopLayout
groups={groups}
selectedGroup={selectedGroup}
selectedTask={selectedTask}
selectedStep={selectedStep}
onGroupSelect={setSelectedGroup}
onTaskSelect={setSelectedTask}
onStepSelect={setSelectedStep}
onPrintTask={handlePrintTask}
onPrintStep={handlePrintStep}
onAddNote={handleAddNote}
/>
) : (
<MobileLayout
groups={groups}
selectedGroup={selectedGroup}
selectedTask={selectedTask}
selectedStep={selectedStep}
onGroupSelect={setSelectedGroup}
onTaskSelect={setSelectedTask}
onStepSelect={setSelectedStep}
onBack={handleBack}
onPrintTask={handlePrintTask}
onPrintStep={handlePrintStep}
onAddNote={handleAddNote}
/>
)}
</div>
);
}

View File

@ -33,6 +33,89 @@ function getComplementaryColors(primaryColor: string): string[] {
return complementaryIndices.map(index => colorPalette[index]);
}
// Check if current time is in night hours (midnight to 6am)
function isNightTime(): boolean {
const hour = new Date().getHours();
return hour >= 0 && hour < 6;
}
// Get fade opacity based on time (0 = completely off, 1 = full brightness)
function getNightFadeOpacity(): number {
if (!isNightTime()) return 1;
const hour = new Date().getHours();
const minute = new Date().getMinutes();
// Calculate fade based on how far into the night we are
// 15-minute fade out starting at midnight, 15-minute fade in starting at 5:45am
const totalMinutes = hour * 60 + minute;
const fadeOutEnd = 15; // 12:15am
const fadeInStart = 5 * 60 + 45; // 5:45am
const fadeInEnd = 6 * 60; // 6:00am
if (totalMinutes <= fadeOutEnd) {
// Fading out: midnight to 12:15am (15 minutes)
const fadeProgress = totalMinutes / fadeOutEnd;
return 1 - fadeProgress; // 1 to 0
} else if (totalMinutes >= fadeInStart) {
// Fading in: 5:45am to 6:00am (15 minutes)
const fadeProgress = (totalMinutes - fadeInStart) / (fadeInEnd - fadeInStart);
return fadeProgress; // 0 to 1
} else {
// Completely off: 12:15am to 5:45am
return 0;
}
}
// Draw 24-hour clock
function drawClock(
ctx: CanvasRenderingContext2D,
width: number,
height: number
) {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
const timeString = `${hours}:${minutes}:${seconds}`;
// Position clock in center
const centerX = width / 2;
const centerY = height / 2;
// Draw clock background
ctx.save();
ctx.globalAlpha = 0.3;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(centerX - 120, centerY - 60, 240, 120);
ctx.restore();
// Draw time text
ctx.save();
ctx.fillStyle = 'white';
ctx.font = 'bold 48px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(timeString, centerX, centerY);
ctx.restore();
// Draw date
const dateString = now.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
ctx.save();
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.font = '16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(dateString, centerX, centerY + 50);
ctx.restore();
}
// Create gradient pattern
function createGradientPattern(
ctx: CanvasRenderingContext2D,
@ -155,18 +238,29 @@ export function Screensaver({ onClose }: ScreensaverProps) {
const animationRef = useRef<number>();
const { primaryColor } = useThemeStore();
const [lastClearTime, setLastClearTime] = useState(Date.now());
const [interactionEnabled, setInteractionEnabled] = useState(false);
useEffect(() => {
console.log('Screensaver: useEffect triggered');
const canvas = canvasRef.current;
if (!canvas) return;
if (!canvas) {
console.error('Screensaver: Canvas ref is null');
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (!ctx) {
console.error('Screensaver: Could not get 2D context');
return;
}
console.log('Screensaver: Canvas and context initialized');
// Set canvas size
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
console.log('Screensaver: Canvas resized to', canvas.width, 'x', canvas.height);
};
resizeCanvas();
@ -175,9 +269,9 @@ export function Screensaver({ onClose }: ScreensaverProps) {
// Get complementary colors
const complementaryColors = getComplementaryColors(primaryColor);
const colors = [primaryColor, ...complementaryColors];
console.log('Screensaver: Using colors', colors);
let startTime = Date.now();
let clearInterval = 0;
const animate = () => {
const currentTime = Date.now();
@ -187,13 +281,6 @@ export function Screensaver({ onClose }: ScreensaverProps) {
if (currentTime - lastClearTime > 180000) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
setLastClearTime(currentTime);
clearInterval = 0;
}
// Clear screen every few seconds for variety
if (clearInterval > 5000) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
clearInterval = 0;
}
// Create gradient background
@ -205,13 +292,28 @@ export function Screensaver({ onClose }: ScreensaverProps) {
// Draw line art
drawLineArt(ctx, canvas.width, canvas.height, colors, elapsed);
clearInterval += 16; // ~60fps
// Apply night fade if needed
const nightFadeOpacity = getNightFadeOpacity();
if (nightFadeOpacity < 1) {
ctx.save();
ctx.globalAlpha = 1 - nightFadeOpacity;
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
}
// Draw clock
drawClock(ctx, canvas.width, canvas.height);
// Schedule next frame before any potential clearing
animationRef.current = requestAnimationFrame(animate);
};
console.log('Screensaver: Starting animation');
animate();
return () => {
console.log('Screensaver: Cleaning up');
window.removeEventListener('resize', resizeCanvas);
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
@ -219,9 +321,24 @@ export function Screensaver({ onClose }: ScreensaverProps) {
};
}, [primaryColor, lastClearTime]);
// Close on any key press or mouse movement
// Enable interaction detection after a delay to prevent immediate closing
useEffect(() => {
const handleInteraction = () => onClose();
const timer = setTimeout(() => {
console.log('Screensaver: Enabling interaction detection');
setInteractionEnabled(true);
}, 1000); // 1 second delay
return () => clearTimeout(timer);
}, []);
// Close on any key press or mouse movement (only when interaction is enabled)
useEffect(() => {
if (!interactionEnabled) return;
const handleInteraction = () => {
console.log('Screensaver: User interaction detected, closing');
onClose();
};
window.addEventListener('keydown', handleInteraction);
window.addEventListener('mousemove', handleInteraction);
@ -234,7 +351,9 @@ export function Screensaver({ onClose }: ScreensaverProps) {
window.removeEventListener('click', handleInteraction);
window.removeEventListener('touchstart', handleInteraction);
};
}, [onClose]);
}, [onClose, interactionEnabled]);
console.log('Screensaver: Rendering component');
return (
<Box
@ -244,8 +363,9 @@ export function Screensaver({ onClose }: ScreensaverProps) {
left: 0,
width: '100vw',
height: '100vh',
zIndex: 9999,
zIndex: 99999,
bgcolor: 'black',
display: 'block',
}}
>
<canvas
@ -254,6 +374,7 @@ export function Screensaver({ onClose }: ScreensaverProps) {
display: 'block',
width: '100%',
height: '100%',
background: 'black',
}}
/>
<IconButton

View File

@ -2,18 +2,27 @@ import { useState } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import { Slideshow } from '@mui/icons-material';
import { Screensaver } from './Screensaver';
import { useAutoScreensaver } from '../hooks/useAutoScreensaver';
export function ScreensaverButton() {
const [screensaverOpen, setScreensaverOpen] = useState(false);
const [manualScreensaverOpen, setManualScreensaverOpen] = useState(false);
const { isActive: autoScreensaverActive, resetTimer } = useAutoScreensaver(5); // 5 minutes
const handleOpenScreensaver = () => {
setScreensaverOpen(true);
console.log('ScreensaverButton: Opening manual screensaver');
setManualScreensaverOpen(true);
};
const handleCloseScreensaver = () => {
setScreensaverOpen(false);
console.log('ScreensaverButton: Closing screensaver');
setManualScreensaverOpen(false);
resetTimer(); // Reset the auto-screensaver timer when manually closed
};
const isScreensaverOpen = manualScreensaverOpen || autoScreensaverActive;
console.log('ScreensaverButton: Rendering, screensaverOpen =', isScreensaverOpen, 'autoActive =', autoScreensaverActive);
return (
<>
<Tooltip title="Launch screensaver">
@ -21,7 +30,7 @@ export function ScreensaverButton() {
<Slideshow />
</IconButton>
</Tooltip>
{screensaverOpen && (
{isScreensaverOpen && (
<Screensaver onClose={handleCloseScreensaver} />
)}
</>

View File

@ -0,0 +1,64 @@
import { useState, useEffect, useCallback } from 'react';
export function useAutoScreensaver(timeoutMinutes: number = 5) {
const [isActive, setIsActive] = useState(false);
const [lastActivity, setLastActivity] = useState(Date.now());
const resetTimer = useCallback(() => {
setLastActivity(Date.now());
if (isActive) {
setIsActive(false);
}
}, [isActive]);
const activateScreensaver = useCallback(() => {
setIsActive(true);
}, []);
// Track user activity
useEffect(() => {
const handleActivity = () => {
resetTimer();
};
// Listen for various user activities
window.addEventListener('mousemove', handleActivity);
window.addEventListener('mousedown', handleActivity);
window.addEventListener('keydown', handleActivity);
window.addEventListener('touchstart', handleActivity);
window.addEventListener('scroll', handleActivity);
window.addEventListener('click', handleActivity);
return () => {
window.removeEventListener('mousemove', handleActivity);
window.removeEventListener('mousedown', handleActivity);
window.removeEventListener('keydown', handleActivity);
window.removeEventListener('touchstart', handleActivity);
window.removeEventListener('scroll', handleActivity);
window.removeEventListener('click', handleActivity);
};
}, [resetTimer]);
// Check for timeout
useEffect(() => {
const timeoutMs = timeoutMinutes * 60 * 1000;
const checkTimeout = () => {
const now = Date.now();
if (now - lastActivity > timeoutMs && !isActive) {
console.log(`Auto-screensaver: Activating after ${timeoutMinutes} minutes of inactivity`);
activateScreensaver();
}
};
const interval = setInterval(checkTimeout, 1000); // Check every second
return () => clearInterval(interval);
}, [lastActivity, isActive, timeoutMinutes, activateScreensaver]);
return {
isActive,
activateScreensaver,
resetTimer,
};
}

View File

@ -20,7 +20,7 @@ function isStepView(selectedTask?: TaskWithSteps, selectedStep?: StepWithNotes)
}
function isGroupWithTasks(group: any): group is GroupWithTasks {
return group && typeof group.id === 'string' && Array.isArray(group.tasks);
return group && (typeof group.id === 'string' || typeof group.id === 'number') && Array.isArray(group.tasks);
}
export function MobileLayout({

View File

@ -0,0 +1,54 @@
version: '3.8'
services:
server:
build:
context: .
dockerfile: server/Dockerfile
container_name: task-receipts-server
ports:
- "4000:4000"
environment:
- NODE_ENV=production
# Printer configuration
- PRINTER_DRIVER=serial
volumes:
# Mount database directory for persistence
- ./server/data/prod.sqlite3:/app/server/prod.sqlite3:rw
# Mount USB devices for printer access
# /dev/bus/usb:/dev/bus/usb:rw
devices:
# Mount USB devices specifically
- /dev/bus/usb:/dev/bus/usb
privileged: true # Required for USB device access
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:4000/graphql', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- task-receipts-network
client:
build:
context: .
dockerfile: client/Dockerfile
container_name: task-receipts-client
ports:
- "80:80"
depends_on:
server:
condition: service_healthy
restart: unless-stopped
networks:
- task-receipts-network
networks:
task-receipts-network:
driver: bridge
volumes:
server-data:
driver: local

46
docker-compose.yml Normal file
View File

@ -0,0 +1,46 @@
version: '3.8'
services:
server:
build:
context: ./server
dockerfile: Dockerfile
container_name: task-receipts-server
ports:
- "4000:4000"
environment:
- NODE_ENV=production
volumes:
# Mount database directory for persistence
- ./server/data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:4000/graphql', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- task-receipts-network
client:
build:
context: ./client
dockerfile: Dockerfile
container_name: task-receipts-client
ports:
- "80:80"
depends_on:
server:
condition: service_healthy
restart: unless-stopped
networks:
- task-receipts-network
networks:
task-receipts-network:
driver: bridge
volumes:
server-data:
driver: local

4272
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,11 +12,5 @@
"test": "npm run test --workspaces",
"lint": "npm run lint --workspaces",
"lint:fix": "npm run lint:fix --workspaces"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1"
}
}

48
server/.dockerignore Normal file
View File

@ -0,0 +1,48 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist/
build/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Logs
logs/
*.log
# Coverage
coverage/
# Test outputs
test-output/
# Database files
*.sqlite3
*.db
# Docker
Dockerfile
.dockerignore

40
server/Dockerfile Normal file
View File

@ -0,0 +1,40 @@
# Build stage
FROM node:18 AS builder
WORKDIR /app
# Copy root package files for workspaces
COPY package.json ./
COPY package-lock.json ./
# Copy server and shared code
COPY server/ ./server/
COPY shared/ ./shared/
# Install dependencies for server workspace
RUN npm ci --workspace=server
# Build the server
WORKDIR /app/server
RUN npm run build
# Production stage
FROM node:18-slim
RUN apt-get update && apt-get install -y dumb-init && rm -rf /var/lib/apt/lists/*
RUN groupadd -r nodejs && useradd -r -g nodejs nodejs
WORKDIR /app/server
# Copy only server package.json and built code
COPY server/package.json ./
COPY --from=builder /app/server/dist ./dist
COPY --from=builder /app/shared ../shared
# Install only production dependencies
RUN npm install --omit=dev && npm cache clean --force
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:4000/graphql', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server/src/index.js"]

8980
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -40,8 +40,8 @@
"@types/jest": "^29.5.12",
"@types/node": "^20.11.24",
"@types/pg": "^8.11.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.34.0",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",

View File

@ -0,0 +1,35 @@
import { Image, Printer } from "@node-escpos/core";
import USB from "@node-escpos/usb-adapter";
import { join } from "path";
async function printImage(path: string) {
const device = new USB();
await new Promise<void>((resolve,reject) => {
device.open(async function(err){
if(err){
reject(err);
return
}
let printer = new Printer(device, {});
const tux = path; // join(__dirname, '../assets/tux.png');
const image = await Image.load(tux);
// inject image to printer
printer = await printer.image(image, "d24")
printer
.cut()
.close()
.finally(resolve)
});
});
}
console.log(process.argv);
printImage(process.argv[2] || join(__dirname, 'tux.png'))
.then(() => console.log('done'))
.catch((err) => console.error(err));

BIN
server/scripts/tux.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -1,4 +1,5 @@
import { Knex } from 'knex';
import logger from '../../logger';
export abstract class BaseRepository<T extends { created_at?: Date; updated_at?: Date }> {
protected tableName: string;
@ -16,6 +17,7 @@ export abstract class BaseRepository<T extends { created_at?: Date; updated_at?:
async findById(id: number): Promise<T | null> {
const result = await this.db(this.tableName).where('id', id).first();
logger.info(`findById(${id}) for table ${this.tableName}:`, result);
return result || null;
}

View File

@ -1,6 +1,7 @@
import { Knex } from 'knex';
import { BaseRepository } from './base-repository';
import { Step, Task } from '@shared/index';
import logger from '../../logger';
export class StepRepository extends BaseRepository<Step> {
constructor(db: Knex) {
@ -8,10 +9,18 @@ export class StepRepository extends BaseRepository<Step> {
}
async findByTaskId(taskId: number): Promise<Step[]> {
return await this.db(this.tableName)
const steps = await this.db(this.tableName)
.where('task_id', taskId)
.orderBy('order')
.select('*');
/*logger.info(`Retrieved ${steps.length} steps for task ${taskId}`);
for (const step of steps) {
logger.info(`Step ${step.id}: name="${step.name}", instructions="${step.instructions}"`);
logger.info(`Full step data:`, JSON.stringify(step, null, 2));
}*/
return steps;
}
async incrementPrintCount(id: number): Promise<boolean> {
@ -54,4 +63,4 @@ export class StepRepository extends BaseRepository<Step> {
return steps.findIndex(s => s.id === stepId) + 1;
}
}
}

View File

@ -60,13 +60,14 @@ async function startServer() {
// YAML Import endpoint
app.post('/api/yaml/import', async (req, res) => {
try {
const { yamlContent } = req.body;
const yamlContent = req.body;
console.log('loading YAML:', yamlContent);
if (!yamlContent || typeof yamlContent !== 'string') {
if (!yamlContent || typeof yamlContent !== 'object') {
return res.status(400).json({ error: 'YAML content is required' });
}
await yamlService.importFromYaml(yamlContent);
await yamlService.importFromYaml(JSON.stringify(yamlContent));
res.json({ message: 'Database imported successfully' });
} catch (error) {
logger.error('Error importing YAML:', error);
@ -101,4 +102,4 @@ async function startServer() {
startServer().catch((error) => {
logger.error('Failed to start server:', error);
process.exit(1);
});
});

View File

@ -0,0 +1,98 @@
import { CommandBuilder } from '../printer-commands';
import { TestCommandExecutor } from '../command-executor';
import { BARCODE_CONFIG } from '../printer-constants';
describe('Barcode Debug', () => {
let commandExecutor: TestCommandExecutor;
beforeEach(() => {
commandExecutor = new TestCommandExecutor();
});
it('should test different barcode formats', async () => {
const testData = '123456789';
// Test basic barcode
const commands = [
CommandBuilder.text('Testing barcode:'),
CommandBuilder.barcode(testData),
CommandBuilder.newline(),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
console.log('Barcode test output:', output);
expect(output).toContain('[BARCODE: 123456789]');
});
it('should test task barcode', async () => {
const taskId = 123;
const commands = [
CommandBuilder.text('Testing task barcode:'),
CommandBuilder.taskBarcode(taskId),
CommandBuilder.newline(),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
console.log('Task barcode test output:', output);
expect(output).toContain('[BARCODE: 100000123]');
});
it('should test step barcode', async () => {
const stepId = 456;
const commands = [
CommandBuilder.text('Testing step barcode:'),
CommandBuilder.stepBarcode(stepId),
CommandBuilder.newline(),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
console.log('Step barcode test output:', output);
expect(output).toContain('[BARCODE: 200000456]');
});
it('should test barcode with alignment', async () => {
const testData = '987654321';
const commands = [
CommandBuilder.text('Testing barcode with alignment:'),
...CommandBuilder.barcodeWithAlignment(testData),
CommandBuilder.newline(),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
console.log('Barcode with alignment test output:', output);
expect(output).toContain('[BARCODE: 987654321]');
});
it('should test task barcode with alignment', async () => {
const taskId = 789;
const commands = [
CommandBuilder.text('Testing task barcode with alignment:'),
...CommandBuilder.taskBarcodeWithAlignment(taskId),
CommandBuilder.newline(),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
console.log('Task barcode with alignment test output:', output);
expect(output).toContain('[BARCODE: 100000789]');
});
it('should test barcode configuration', () => {
console.log('Barcode config:', BARCODE_CONFIG);
expect(BARCODE_CONFIG.TYPE).toBe('CODE39');
expect(BARCODE_CONFIG.ALTERNATIVE_TYPES).toContain('CODE128');
});
});

View File

@ -0,0 +1,167 @@
import { BarcodeUtils, BarcodeData } from '../barcode-utils';
describe('BarcodeUtils', () => {
describe('parseBarcode', () => {
it('should parse valid task barcode', () => {
const result = BarcodeUtils.parseBarcode('100000123');
expect(result).toEqual({ type: 'TASK', id: 123 });
});
it('should parse valid step barcode', () => {
const result = BarcodeUtils.parseBarcode('200000456');
expect(result).toEqual({ type: 'STEP', id: 456 });
});
it('should parse barcode with non-numeric characters', () => {
const result = BarcodeUtils.parseBarcode('1ABC000123DEF');
expect(result).toEqual({ type: 'TASK', id: 123 });
});
it('should return null for invalid entity type', () => {
const result = BarcodeUtils.parseBarcode('300000123');
expect(result).toBeNull();
});
it('should return null for too short barcode', () => {
const result = BarcodeUtils.parseBarcode('1');
expect(result).toBeNull();
});
it('should return null for invalid ID', () => {
const result = BarcodeUtils.parseBarcode('100000000');
expect(result).toBeNull();
});
it('should return null for empty string', () => {
const result = BarcodeUtils.parseBarcode('');
expect(result).toBeNull();
});
it('should parse large IDs correctly', () => {
const result = BarcodeUtils.parseBarcode('199999999');
expect(result).toEqual({ type: 'TASK', id: 99999999 });
});
});
describe('generateTaskBarcode', () => {
it('should generate task barcode', () => {
const result = BarcodeUtils.generateTaskBarcode(123);
expect(result).toBe('100000123');
});
it('should generate task barcode with padding', () => {
const result = BarcodeUtils.generateTaskBarcode(1);
expect(result).toBe('100000001');
});
});
describe('generateStepBarcode', () => {
it('should generate step barcode', () => {
const result = BarcodeUtils.generateStepBarcode(456);
expect(result).toBe('200000456');
});
it('should generate step barcode with padding', () => {
const result = BarcodeUtils.generateStepBarcode(1);
expect(result).toBe('200000001');
});
});
describe('isValidBarcode', () => {
it('should return true for valid task barcode', () => {
const result = BarcodeUtils.isValidBarcode('100000123');
expect(result).toBe(true);
});
it('should return true for valid step barcode', () => {
const result = BarcodeUtils.isValidBarcode('200000456');
expect(result).toBe(true);
});
it('should return false for invalid barcode', () => {
const result = BarcodeUtils.isValidBarcode('300000123');
expect(result).toBe(false);
});
});
describe('getEntityType', () => {
it('should return TASK for task barcode', () => {
const result = BarcodeUtils.getEntityType('100000123');
expect(result).toBe('TASK');
});
it('should return STEP for step barcode', () => {
const result = BarcodeUtils.getEntityType('200000456');
expect(result).toBe('STEP');
});
it('should return null for invalid barcode', () => {
const result = BarcodeUtils.getEntityType('300000123');
expect(result).toBeNull();
});
});
describe('getEntityId', () => {
it('should return ID for task barcode', () => {
const result = BarcodeUtils.getEntityId('100000123');
expect(result).toBe(123);
});
it('should return ID for step barcode', () => {
const result = BarcodeUtils.getEntityId('200000456');
expect(result).toBe(456);
});
it('should return null for invalid barcode', () => {
const result = BarcodeUtils.getEntityId('300000123');
expect(result).toBeNull();
});
});
describe('getMaxId', () => {
it('should return maximum supported ID', () => {
const result = BarcodeUtils.getMaxId();
expect(result).toBe(99999999);
});
});
describe('isValidId', () => {
it('should return true for valid ID', () => {
const result = BarcodeUtils.isValidId(123);
expect(result).toBe(true);
});
it('should return false for zero ID', () => {
const result = BarcodeUtils.isValidId(0);
expect(result).toBe(false);
});
it('should return false for negative ID', () => {
const result = BarcodeUtils.isValidId(-1);
expect(result).toBe(false);
});
it('should return false for ID too large', () => {
const result = BarcodeUtils.isValidId(100000000);
expect(result).toBe(false);
});
});
describe('round-trip tests', () => {
it('should generate and parse task barcode correctly', () => {
const taskId = 789;
const generated = BarcodeUtils.generateTaskBarcode(taskId);
const parsed = BarcodeUtils.parseBarcode(generated);
expect(parsed).toEqual({ type: 'TASK', id: taskId });
});
it('should generate and parse step barcode correctly', () => {
const stepId = 101;
const generated = BarcodeUtils.generateStepBarcode(stepId);
const parsed = BarcodeUtils.parseBarcode(generated);
expect(parsed).toEqual({ type: 'STEP', id: stepId });
});
});
});

View File

@ -0,0 +1,133 @@
import { CommandBuilder } from '../printer-commands';
import { TestCommandExecutor } from '../command-executor';
describe('Command System (Standalone)', () => {
let commandExecutor: TestCommandExecutor;
beforeEach(() => {
commandExecutor = new TestCommandExecutor();
});
it('should execute text commands', async () => {
const commands = [
CommandBuilder.text('Hello World'),
CommandBuilder.newline(),
CommandBuilder.text('Test'),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('Hello World');
expect(output).toContain('Test');
});
it('should execute header commands', async () => {
const commands = [
CommandBuilder.header('Test Header'),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('[ ] Test Header');
});
it('should execute banner commands', async () => {
const commands = [
CommandBuilder.banner('=', 10),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('==========');
});
it('should execute barcode commands', async () => {
const commands = [
CommandBuilder.barcode('123456'),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('[BARCODE: 123456]');
});
it('should execute section commands', async () => {
const commands = [
CommandBuilder.section('Test Section', 'Test Content', '-', true),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('[ ] Test Section');
expect(output).toContain('Test Content');
});
it('should execute step header commands', async () => {
const commands = [
CommandBuilder.stepHeader('Test Step', 1, 'Test Task', true),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('[ ] Step 1: Test Step');
});
it('should execute cut commands', async () => {
const commands = [
CommandBuilder.cut(true, 4),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('--- CUT ---');
});
it('should execute complex command sequences', async () => {
const commands = [
CommandBuilder.header('Complex Test'),
CommandBuilder.banner('=', 20),
CommandBuilder.text('Some content'),
CommandBuilder.newline(),
CommandBuilder.barcode('123'),
CommandBuilder.cut(),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('[ ] Complex Test');
expect(output).toContain('====================');
expect(output).toContain('Some content');
expect(output).toContain('[BARCODE: 123]');
expect(output).toContain('--- CUT ---');
});
it('should handle empty command arrays', async () => {
const commands: any[] = [];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toBe('\n');
});
it('should handle list commands', async () => {
const commands = [
CommandBuilder.list(['Item 1', 'Item 2', 'Item 3'], 1, '- '),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('- [ ] 1: Item 1');
expect(output).toContain('- [ ] 2: Item 2');
expect(output).toContain('- [ ] 3: Item 3');
});
});

View File

@ -1,144 +1,51 @@
import { formatUtils } from '../format-utils';
import { Command } from '../printer-commands';
describe('formatUtils', () => {
describe('createBanner', () => {
it('should create a banner with default length', () => {
const banner = formatUtils.createBanner('=');
expect(banner).toBe('='.repeat(40));
});
it('should create a banner with custom length', () => {
const banner = formatUtils.createBanner('-', 32);
expect(banner).toBe('-'.repeat(32));
});
it('should handle empty character', () => {
const banner = formatUtils.createBanner('', 10);
expect(banner).toBe('');
});
it('should handle zero length', () => {
const banner = formatUtils.createBanner('*', 0);
expect(banner).toBe('');
});
it('banner returns a BANNER command', () => {
expect(formatUtils.banner('=', 10)).toEqual([Command.BANNER, '=', 10]);
});
describe('formatCheckbox', () => {
it('should format text with checkbox', () => {
const formatted = formatUtils.formatCheckbox('Test Item');
expect(formatted).toBe('[ ] Test Item');
});
it('should handle empty text', () => {
const formatted = formatUtils.formatCheckbox('');
expect(formatted).toBe('[ ] ');
});
it('should handle text with special characters', () => {
const formatted = formatUtils.formatCheckbox('Test: Item (123)');
expect(formatted).toBe('[ ] Test: Item (123)');
});
it('checkbox returns a CHECKBOX command', () => {
expect(formatUtils.checkbox('Test')).toEqual([Command.CHECKBOX, 'Test']);
});
describe('formatList', () => {
it('should format list without numbering', () => {
const items = ['Item 1', 'Item 2', 'Item 3'];
const formatted = formatUtils.formatList(items);
expect(formatted).toEqual([
'[ ] Item 1',
'[ ] Item 2',
'[ ] Item 3'
]);
});
it('should format list with numbering', () => {
const items = ['Item 1', 'Item 2', 'Item 3'];
const formatted = formatUtils.formatList(items, 1);
expect(formatted).toEqual([
'[ ] 1: Item 1',
'[ ] 2: Item 2',
'[ ] 3: Item 3'
]);
});
it('should handle empty list', () => {
const formatted = formatUtils.formatList([]);
expect(formatted).toEqual([]);
});
it('should handle list with empty items', () => {
const items = ['', 'Item 2', ''];
const formatted = formatUtils.formatList(items);
expect(formatted).toEqual([
'[ ] ',
'[ ] Item 2',
'[ ] '
]);
});
it('list returns a LIST command', () => {
expect(formatUtils.list(['A', 'B'], 1, '- ')).toEqual([[Command.LIST, ['A', 'B'], 1, '- ']]);
});
describe('formatSection', () => {
it('should format section with default banner', () => {
const section = formatUtils.formatSection('Header', 'Content');
expect(section).toEqual([
'[ ] Header',
'='.repeat(40),
'Content',
]);
});
it('should format section with custom banner', () => {
const section = formatUtils.formatSection('Header', 'Content', '-');
expect(section).toEqual([
'[ ] Header',
'-'.repeat(40),
'Content',
]);
});
it('should handle empty content', () => {
const section = formatUtils.formatSection('Header', '');
expect(section).toEqual([
'[ ] Header',
'='.repeat(40),
'',
]);
});
it('should handle empty header', () => {
const section = formatUtils.formatSection('', 'Content');
expect(section).toEqual([
'[ ] ',
'='.repeat(40),
'Content',
]);
});
it('stepHeader returns a CHECKBOX command with formatted text', () => {
expect(formatUtils.stepHeader('StepName', 2, 'TaskName', true)).toEqual([Command.CHECKBOX, 'Step 2: StepName']);
expect(formatUtils.stepHeader('StepName', 2, 'TaskName', false)).toEqual([Command.CHECKBOX, 'Step 2 of TaskName: StepName']);
});
describe('formatStepHeader', () => {
it('should format step header with just step name and number', () => {
const header = formatUtils.formatStepHeader('Test Step', 1);
expect(header).toBe('Step 1: Test Step');
});
it('section returns a list of commands', () => {
expect(formatUtils.section('Header', 'Content')).toEqual([
[Command.CHECKBOX, 'Header'],
[Command.BANNER, '='],
[Command.TEXT, 'Content'],
]);
expect(formatUtils.section('Header', 'Content', '-', true)).toEqual([
[Command.CHECKBOX, 'Header'],
[Command.BANNER, '-'],
[Command.TEXT, 'Content'],
[Command.NEWLINE],
]);
});
it('should format step header with step number and task name in single step view', () => {
const header = formatUtils.formatStepHeader('Test Step', 1, 'Test Task');
expect(header).toBe('Step 1 of Test Task: Test Step');
});
it('getCheckboxText extracts text from a CHECKBOX command', () => {
expect(formatUtils.getCheckboxText([Command.CHECKBOX, 'Hello'])).toBe('Hello');
expect(formatUtils.getCheckboxText([Command.BANNER, '='])).toBe('');
});
it('should format step header with step number but no task name in task view', () => {
const header = formatUtils.formatStepHeader('Test Step', 1, 'Test Task', true);
expect(header).toBe('Step 1: Test Step');
});
it('taskHeader returns a CHECKBOX command for the task', () => {
expect(formatUtils.taskHeader('My Task')).toEqual([Command.CHECKBOX, 'Task: My Task']);
});
it('should handle empty step name', () => {
const header = formatUtils.formatStepHeader('', 1);
expect(header).toBe('Step 1: ');
});
it('should handle zero step number', () => {
const header = formatUtils.formatStepHeader('Test Step', 0);
expect(header).toBe('Step 0: Test Step');
});
it('taskSection returns a list of commands for the task section', () => {
expect(formatUtils.taskSection('My Task')).toEqual([
[Command.CHECKBOX, 'Task: My Task'],
[Command.BANNER, '='],
]);
});
});

View File

@ -3,6 +3,8 @@ import { TestPrinter } from '../index';
import { Task, Step } from '@shared/index';
import { InMemoryPrintHistoryRepository, InMemoryStepRepository } from '../../db/repositories/in-memory-repository';
import { Knex } from 'knex';
import { CommandBuilder } from '../printer-commands';
import { TestCommandExecutor } from '../command-executor';
describe('Printer', () => {
let printHistoryRepo: InMemoryPrintHistoryRepository;
@ -40,4 +42,113 @@ describe('Printer', () => {
printed_at: expect.any(Date),
});
});
});
describe('Command System', () => {
let commandExecutor: TestCommandExecutor;
beforeEach(() => {
commandExecutor = new TestCommandExecutor();
});
it('should execute text commands', async () => {
const commands = [
CommandBuilder.text('Hello World'),
CommandBuilder.newline(),
CommandBuilder.text('Test'),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('Hello World');
expect(output).toContain('Test');
});
it('should execute header commands', async () => {
const commands = [
CommandBuilder.header('Test Header'),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('[ ] Test Header');
});
it('should execute banner commands', async () => {
const commands = [
CommandBuilder.banner('=', 10),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('==========');
});
it('should execute barcode commands', async () => {
const commands = [
CommandBuilder.barcode('123456'),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('[BARCODE: 123456]');
});
it('should execute section commands', async () => {
const commands = [
CommandBuilder.section('Test Section', 'Test Content', '-', true),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('[ ] Test Section');
expect(output).toContain('Test Content');
});
it('should execute step header commands', async () => {
const commands = [
CommandBuilder.stepHeader('Test Step', 1, 'Test Task', true),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('[ ] Step 1: Test Step');
});
it('should execute cut commands', async () => {
const commands = [
CommandBuilder.cut(true, 4),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('--- CUT ---');
});
it('should execute complex command sequences', async () => {
const commands = [
CommandBuilder.header('Complex Test'),
CommandBuilder.banner('=', 20),
CommandBuilder.text('Some content'),
CommandBuilder.newline(),
CommandBuilder.barcode('123'),
CommandBuilder.cut(),
];
await commandExecutor.executeCommands(commands);
const output = commandExecutor.getOutput();
expect(output).toContain('[ ] Complex Test');
expect(output).toContain('====================');
expect(output).toContain('Some content');
expect(output).toContain('[BARCODE: 123]');
expect(output).toContain('--- CUT ---');
});
});

View File

@ -0,0 +1,51 @@
import { Step } from '@shared/index';
describe('Step Data Debug', () => {
it('should test step data structure', () => {
// Test with sample step data that matches the database
const step: Step = {
id: 10,
name: 'Counter',
instructions: 'Was hit',
task_id: 20,
order: 1,
print_count: 9,
created_at: new Date('2025-06-15T01:55:41.121Z'),
updated_at: new Date('2025-06-15T01:55:41.121Z'),
};
console.log(`Step ${step.id}:`);
console.log(` name: "${step.name}"`);
console.log(` instructions: "${step.instructions}"`);
console.log(` task_id: ${step.task_id}`);
console.log(` order: ${step.order}`);
console.log(` print_count: ${step.print_count}`);
console.log(` created_at: ${step.created_at}`);
console.log(` updated_at: ${step.updated_at}`);
expect(step.instructions).toBe('Was hit');
expect(typeof step.instructions).toBe('string');
});
it('should test step data with different instructions', () => {
const step: Step = {
id: 11,
name: 'Sink',
instructions: 'asdf',
task_id: 20,
order: 2,
print_count: 2,
created_at: new Date('2025-06-15T01:55:46.466Z'),
updated_at: new Date('2025-06-15T01:55:46.466Z'),
};
console.log(`Step ${step.id}:`);
console.log(` name: "${step.name}"`);
console.log(` instructions: "${step.instructions}"`);
console.log(` task_id: ${step.task_id}`);
console.log(` order: ${step.order}`);
expect(step.instructions).toBe('asdf');
expect(typeof step.instructions).toBe('string');
});
});

View File

@ -0,0 +1,106 @@
export interface BarcodeData {
type: 'TASK' | 'STEP';
id: number;
}
export class BarcodeUtils {
// Entity type codes - using numeric prefixes
private static readonly TASK_PREFIX = 1;
private static readonly STEP_PREFIX = 2;
// Format: [entity_type][padding][id]
// Example: 100000123 for task 123, 200000456 for step 456
private static readonly ID_PADDING = 4; // 4 digits for ID
/**
* Parse barcode data to extract entity type and ID
* @param barcodeData The raw barcode data (e.g., "100000123" or "200000456")
* @returns Parsed barcode data with type and ID, or null if invalid
*/
static parseBarcode(barcodeData: string): BarcodeData | null {
const numericData = barcodeData.replace(/\D/g, ''); // Remove non-digits
if (numericData.length < 2) {
return null;
}
const entityType = parseInt(numericData.charAt(0), 10);
const id = parseInt(numericData.substring(1), 10);
if (isNaN(id) || id <= 0) {
return null;
}
if (entityType === this.TASK_PREFIX) {
return { type: 'TASK', id };
} else if (entityType === this.STEP_PREFIX) {
return { type: 'STEP', id };
}
return null;
}
/**
* Generate barcode data for a task
* @param taskId The task ID
* @returns Formatted barcode data (numeric only)
*/
static generateTaskBarcode(taskId: number): string {
return `${this.TASK_PREFIX}${taskId.toString().padStart(this.ID_PADDING, '0')}`;
}
/**
* Generate barcode data for a step
* @param stepId The step ID
* @returns Formatted barcode data (numeric only)
*/
static generateStepBarcode(stepId: number): string {
return `${this.STEP_PREFIX}${stepId.toString().padStart(this.ID_PADDING, '0')}`;
}
/**
* Validate if a barcode data string is valid
* @param barcodeData The barcode data to validate
* @returns True if valid, false otherwise
*/
static isValidBarcode(barcodeData: string): boolean {
return this.parseBarcode(barcodeData) !== null;
}
/**
* Get the entity type from barcode data
* @param barcodeData The barcode data
* @returns The entity type or null if invalid
*/
static getEntityType(barcodeData: string): 'TASK' | 'STEP' | null {
const parsed = this.parseBarcode(barcodeData);
return parsed?.type || null;
}
/**
* Get the entity ID from barcode data
* @param barcodeData The barcode data
* @returns The entity ID or null if invalid
*/
static getEntityId(barcodeData: string): number | null {
const parsed = this.parseBarcode(barcodeData);
return parsed?.id || null;
}
/**
* Get the maximum supported ID for the current format
* @returns Maximum ID value
*/
static getMaxId(): number {
return Math.pow(10, this.ID_PADDING) - 1;
}
/**
* Check if an ID is within the supported range
* @param id The ID to check
* @returns True if valid, false otherwise
*/
static isValidId(id: number): boolean {
return id > 0 && id <= this.getMaxId();
}
}

View File

@ -0,0 +1,268 @@
import { Printer as EscposPrinter } from '@node-escpos/core';
import { formatUtils } from './format-utils';
import { Command, CommandTuple, CommandArray } from './printer-commands';
import { FONT_SIZES, ALIGNMENT, FONT, STYLE, BARCODE_CONFIG } from './printer-constants';
import logger from '../logger';
export interface CommandExecutor {
executeCommands(commands: CommandArray): Promise<void>;
}
export class SerialCommandExecutor implements CommandExecutor {
constructor(private printer: EscposPrinter<[]>) {}
async executeCommands(commands: CommandArray): Promise<void> {
for (const command of commands) {
await this.executeCommand(command);
}
}
private async executeCommand(commandTuple: CommandTuple): Promise<void> {
const [command, ...params] = commandTuple;
switch (command) {
case Command.TEXT:
await this.printer.text(params[0] as string);
break;
case Command.HEADER:
await this.printer
.font(FONT.DEFAULT)
.align(ALIGNMENT.CENTER)
.style(STYLE.BOLD)
.size(FONT_SIZES.LARGE.width, FONT_SIZES.LARGE.height)
.text(formatUtils.getCheckboxText(formatUtils.checkbox(params[0] as string)));
break;
case Command.BANNER:
const char = params[0] as string;
const length = params[1] as number;
await this.printer.text(formatUtils.bannerString(char, length));
break;
case Command.NEWLINE:
await this.printer.text('');
break;
case Command.BARCODE:
const barcodeData = params[0] as string;
logger.info(`Printing barcode: ${barcodeData} with type: ${BARCODE_CONFIG.TYPE}`);
// Try multiple barcode formats until one works
const barcodeTypes = [BARCODE_CONFIG.TYPE, ...BARCODE_CONFIG.ALTERNATIVE_TYPES];
let barcodePrinted = false;
for (const barcodeType of barcodeTypes) {
try {
logger.info(`Trying barcode type: ${barcodeType}`);
await this.printer.barcode(
barcodeData,
barcodeType,
BARCODE_CONFIG.DIMENSIONS
);
logger.info(`Successfully printed barcode with type: ${barcodeType}`);
barcodePrinted = true;
break;
} catch (error) {
logger.warn(`Barcode type ${barcodeType} failed: ${error}`);
continue;
}
}
if (!barcodePrinted) {
logger.error(`All barcode types failed for data: ${barcodeData}`);
// As a last resort, just print the data as text
await this.printer.text(`[BARCODE: ${barcodeData}]`);
}
break;
case Command.FONT_SIZE:
const size = params[0] as typeof FONT_SIZES[keyof typeof FONT_SIZES];
await this.printer.size(size.width, size.height);
break;
case Command.ALIGN:
await this.printer.align(params[0] as any);
break;
case Command.FONT_FAMILY:
await this.printer.font(params[0] as any);
break;
case Command.STYLE:
await this.printer.style(params[0] as any);
break;
case Command.CUT:
const partial = params[0] as boolean;
const lines = params[1] as number;
await this.printer.cut(partial, lines);
break;
case Command.SECTION:
const header = params[0] as string;
const content = params[1] as string;
const bannerChar = params[2] as string;
const trailingNewline = params[3] as boolean;
const section = formatUtils.section(header, content, bannerChar, trailingNewline);
for (const cmd of section) {
if (cmd[0] === Command.CHECKBOX || cmd[0] === Command.TEXT) {
await this.printer.text(cmd[1] as string);
} else if (cmd[0] === Command.BANNER) {
await this.printer.text(formatUtils.bannerString(cmd[1] as string, cmd[2] as number));
} else if (cmd[0] === Command.NEWLINE) {
await this.printer.text('');
}
}
break;
case Command.STEP_HEADER:
const stepName = params[0] as string;
const stepNumber = params[1] as number;
const taskName = params[2] as string;
const isTaskView = params[3] as boolean;
const stepHeader = formatUtils.stepHeader(stepName, stepNumber, taskName, isTaskView);
await this.printer.text(formatUtils.getCheckboxText(stepHeader));
break;
case Command.CHECKBOX:
const text = params[0] as string;
const checkbox = `[ ] ${text}`;
await this.printer.text(checkbox);
break;
case Command.LIST:
const items = params[0] as string[];
const startIndex = params[1] as number;
const prefix = params[2] as string;
const listCmds = formatUtils.list(items, startIndex, prefix);
for (const cmd of listCmds) {
if (cmd[0] === Command.LIST) {
// Render as text for test printer, or as needed for real printer
for (let i = 0; i < items.length; i++) {
const num = startIndex > 0 ? `${i + startIndex}: ` : '';
const body = formatUtils.getCheckboxText(formatUtils.checkbox(`${num}${items[i]}`));
await this.printer.text(`${prefix}${body}`);
}
}
}
break;
default:
throw new Error(`Unknown command: ${command}`);
}
}
}
export class TestCommandExecutor implements CommandExecutor {
private output: string[] = [];
async executeCommands(commands: CommandArray): Promise<void> {
this.output = [];
for (const command of commands) {
await this.executeCommand(command);
}
}
private async executeCommand(commandTuple: CommandTuple): Promise<void> {
const [command, ...params] = commandTuple;
switch (command) {
case Command.TEXT:
this.output.push(params[0] as string);
break;
case Command.HEADER:
this.output.push(formatUtils.getCheckboxText(formatUtils.checkbox(params[0] as string)));
break;
case Command.BANNER:
const char = params[0] as string;
const length = params[1] as number;
this.output.push(formatUtils.banner(char, length)[1] as string);
break;
case Command.NEWLINE:
this.output.push('');
break;
case Command.BARCODE:
this.output.push(`[BARCODE: ${params[0] as string}]`);
break;
case Command.FONT_SIZE:
// Test printer ignores font size changes
break;
case Command.ALIGN:
// Test printer ignores alignment changes
break;
case Command.FONT_FAMILY:
// Test printer ignores font changes
break;
case Command.STYLE:
// Test printer ignores style changes
break;
case Command.CUT:
this.output.push('--- CUT ---');
break;
case Command.SECTION:
const header = params[0] as string;
const content = params[1] as string;
const bannerChar = params[2] as string;
const trailingNewline = params[3] as boolean;
const section = formatUtils.section(header, content, bannerChar, trailingNewline);
for (const cmd of section) {
if (cmd[0] === Command.CHECKBOX || cmd[0] === Command.TEXT) {
this.output.push(cmd[1] as string);
} else if (cmd[0] === Command.BANNER) {
this.output.push(formatUtils.bannerString(cmd[1] as string, cmd[2] as number));
} else if (cmd[0] === Command.NEWLINE) {
this.output.push('');
}
}
break;
case Command.STEP_HEADER:
const stepName = params[0] as string;
const stepNumber = params[1] as number;
const taskName = params[2] as string;
const isTaskView = params[3] as boolean;
const stepHeader = formatUtils.stepHeader(stepName, stepNumber, taskName, isTaskView);
this.output.push(formatUtils.getCheckboxText(stepHeader));
break;
case Command.CHECKBOX:
this.output.push(formatUtils.getCheckboxText(formatUtils.checkbox(params[0] as string)));
break;
case Command.LIST:
const items = params[0] as string[];
const startIndex = params[1] as number;
const prefix = params[2] as string;
const listCmds = formatUtils.list(items, startIndex, prefix);
for (const cmd of listCmds) {
if (cmd[0] === Command.LIST) {
for (let i = 0; i < items.length; i++) {
const num = startIndex > 0 ? `${i + startIndex}: ` : '';
const body = formatUtils.getCheckboxText(formatUtils.checkbox(`${num}${items[i]}`));
this.output.push(`${prefix}${body}`);
}
}
}
break;
default:
throw new Error(`Unknown command: ${command}`);
}
}
getOutput(): string {
return this.output.join('\n') + '\n';
}
}

View File

@ -1,48 +1,39 @@
import { PAPER_CONFIG } from "./printer-constants";
import { Command, CommandTuple } from './printer-commands';
export const formatUtils = {
/**
* Creates a banner line with the specified character
* @param char Character to repeat
* @param length Length of the banner
* @returns Formatted banner string
* Creates a banner command
*/
createBanner(char: string, length: number = PAPER_CONFIG.BANNER_LENGTH): string {
banner(char: string, length: number = PAPER_CONFIG.BANNER_LENGTH): CommandTuple {
return [Command.BANNER, char, length];
},
/**
* Creates a banner string (repeated character)
*/
bannerString(char: string, length: number = PAPER_CONFIG.BANNER_LENGTH): string {
return char.repeat(length);
},
/**
* Formats a checkbox item with the given text
* @param text Text to display after the checkbox
* @returns Formatted checkbox string
* Checkbox command
*/
formatCheckbox(text: string): string {
return `[ ] ${text}`;
checkbox(text: string): CommandTuple {
return [Command.CHECKBOX, text];
},
/**
* Formats a list of items into lines with optional numbering
* @param items List of items to format
* @param startIndex Starting index for numbering (0 for no numbers)
* @returns Array of formatted lines
* List of items as commands
*/
formatList(items: string[], startIndex: number = 0, prefix: string = ''): string[] {
return items.map((item, index) => {
const num = startIndex > 0 ? `${index + startIndex}: ` : '';
const body = this.formatCheckbox(`${num}${item}`);
return `${prefix}${body}`;
});
list(items: string[], startIndex: number = 0, prefix: string = ''): CommandTuple[] {
return [[Command.LIST, items, startIndex, prefix]];
},
/**
* Formats a step header with task context
* @param stepName Name of the step
* @param stepNumber Step number (1-based)
* @param taskName Name of the parent task (only used in single step view)
* @param isTaskView Whether this is being used in a task view
* @returns Formatted step header
* Step header as a command
*/
formatStepHeader(stepName: string, stepNumber: number, taskName?: string, isTaskView: boolean = false): string {
stepHeader(stepName: string, stepNumber: number, taskName?: string, isTaskView: boolean = false): CommandTuple {
const parts = ['Step'];
if (stepNumber !== undefined) {
parts.push(' ');
@ -53,25 +44,48 @@ export const formatUtils = {
}
parts.push(': ');
parts.push(stepName);
return parts.join('');
return [Command.CHECKBOX, parts.join('')];
},
/**
* Formats a section with a header and content
* @param header Section header
* @param content Section content
* @param bannerChar Character to use for the banner
* @returns Array of formatted lines
* Section as a list of commands
*/
formatSection(header: string, content: string, bannerChar: string = '=', trailingNewline: boolean = false): string[] {
const parts = [
this.formatCheckbox(header),
this.createBanner(bannerChar),
content,
section(header: string, content: string, bannerChar: string = '=', trailingNewline: boolean = false): CommandTuple[] {
const commands: CommandTuple[] = [
[Command.CHECKBOX, header],
[Command.BANNER, bannerChar],
[Command.TEXT, content],
];
if (trailingNewline) {
parts.push('');
commands.push([Command.NEWLINE]);
}
return parts;
}
return commands;
},
/**
* Extracts the text from a CHECKBOX CommandTuple
*/
getCheckboxText(cmd: CommandTuple): string {
if (cmd[0] === Command.CHECKBOX && typeof cmd[1] === 'string') {
return cmd[1];
}
return '';
},
/**
* Task header as a command
*/
taskHeader(taskName: string): CommandTuple {
return [Command.CHECKBOX, `Task: ${taskName}`];
},
/**
* Task section as a list of commands
*/
taskSection(taskName: string): CommandTuple[] {
return [
[Command.CHECKBOX, `Task: ${taskName}`],
[Command.BANNER, '='],
];
},
};

View File

@ -16,3 +16,6 @@ export function createPrinter(printHistoryRepo: PrintHistoryRepository, stepRepo
export { SerialPrinter } from './serial-printer';
export { TestPrinter } from './test-printer';
export { CommandBuilder, Command, type CommandArray, type CommandTuple } from './printer-commands';
export { SerialCommandExecutor, TestCommandExecutor, type CommandExecutor } from './command-executor';
export { BarcodeUtils, type BarcodeData } from './barcode-utils';

View File

@ -0,0 +1,148 @@
import { FONT_SIZES, ALIGNMENT, FONT, STYLE, BARCODE_CONFIG, PAPER_CONFIG } from './printer-constants';
import { BarcodeUtils } from './barcode-utils';
// Command types for printer operations
export enum Command {
// Text commands
TEXT = 'TEXT',
HEADER = 'HEADER',
BANNER = 'BANNER',
NEWLINE = 'NEWLINE',
// Barcode commands
BARCODE = 'BARCODE',
// Formatting commands
FONT_SIZE = 'FONT_SIZE',
ALIGN = 'ALIGN',
FONT_FAMILY = 'FONT_FAMILY',
STYLE = 'STYLE',
// Paper commands
CUT = 'CUT',
// Section formatting
SECTION = 'SECTION',
STEP_HEADER = 'STEP_HEADER',
CHECKBOX = 'CHECKBOX',
// List formatting
LIST = 'LIST',
}
// Command parameter types
export type FontSize = typeof FONT_SIZES[keyof typeof FONT_SIZES];
export type Alignment = typeof ALIGNMENT[keyof typeof ALIGNMENT];
export type FontFamily = typeof FONT[keyof typeof FONT];
export type TextStyle = typeof STYLE[keyof typeof STYLE];
// Command tuple type - [Command, ...parameters]
export type CommandTuple =
| [Command.TEXT, string]
| [Command.HEADER, string]
| [Command.BANNER, string, number?]
| [Command.NEWLINE]
| [Command.BARCODE, string]
| [Command.FONT_SIZE, FontSize]
| [Command.ALIGN, Alignment]
| [Command.FONT_FAMILY, FontFamily]
| [Command.STYLE, TextStyle]
| [Command.CUT, boolean, number?]
| [Command.SECTION, string, string, string?, boolean?]
| [Command.STEP_HEADER, string, number, string?, boolean?]
| [Command.CHECKBOX, string]
| [Command.LIST, string[], number?, string?];
// Command array type
export type CommandArray = CommandTuple[];
// Command builder utility
export class CommandBuilder {
static text(text: string): CommandTuple {
return [Command.TEXT, text];
}
static header(text: string): CommandTuple {
return [Command.HEADER, text];
}
static banner(char: string = '=', length?: number): CommandTuple {
return [Command.BANNER, char, length];
}
static newline(): CommandTuple {
return [Command.NEWLINE];
}
static barcode(data: string): CommandTuple {
return [Command.BARCODE, data];
}
static taskBarcode(taskId: number): CommandTuple {
return [Command.BARCODE, BarcodeUtils.generateTaskBarcode(taskId)];
}
static stepBarcode(stepId: number): CommandTuple {
return [Command.BARCODE, BarcodeUtils.generateStepBarcode(stepId)];
}
static barcodeWithAlignment(data: string, alignment: Alignment = ALIGNMENT.CENTER): CommandTuple[] {
return [
[Command.ALIGN, alignment],
[Command.BARCODE, data],
[Command.ALIGN, ALIGNMENT.LEFT], // Reset to left alignment
];
}
static taskBarcodeWithAlignment(taskId: number, alignment: Alignment = ALIGNMENT.CENTER): CommandTuple[] {
return [
[Command.ALIGN, alignment],
[Command.BARCODE, BarcodeUtils.generateTaskBarcode(taskId)],
[Command.ALIGN, ALIGNMENT.LEFT], // Reset to left alignment
];
}
static stepBarcodeWithAlignment(stepId: number, alignment: Alignment = ALIGNMENT.CENTER): CommandTuple[] {
return [
[Command.ALIGN, alignment],
[Command.BARCODE, BarcodeUtils.generateStepBarcode(stepId)],
[Command.ALIGN, ALIGNMENT.LEFT], // Reset to left alignment
];
}
static fontSize(size: FontSize): CommandTuple {
return [Command.FONT_SIZE, size];
}
static align(alignment: Alignment): CommandTuple {
return [Command.ALIGN, alignment];
}
static font(font: FontFamily): CommandTuple {
return [Command.FONT_FAMILY, font];
}
static style(style: TextStyle): CommandTuple {
return [Command.STYLE, style];
}
static cut(partial: boolean = true, lines: number = PAPER_CONFIG.CUT_LINES): CommandTuple {
return [Command.CUT, partial, lines];
}
static section(header: string, content: string, bannerChar: string = '=', trailingNewline: boolean = false): CommandTuple {
return [Command.SECTION, header, content, bannerChar, trailingNewline];
}
static stepHeader(stepName: string, stepNumber: number, taskName?: string, isTaskView: boolean = false): CommandTuple {
return [Command.STEP_HEADER, stepName, stepNumber, taskName, isTaskView];
}
static checkbox(text: string): CommandTuple {
return [Command.CHECKBOX, text];
}
static list(items: string[], startIndex: number = 0, prefix: string = ''): CommandTuple {
return [Command.LIST, items, startIndex, prefix];
}
}

View File

@ -5,13 +5,14 @@ export const PRINTER_CONFIG = {
export const FONT_SIZES = {
NORMAL: { width: 1, height: 1 }, // 0.08 x 2.13 mm
SMALL: { width: 1, height: 1 }, // Same as normal for now, but can be adjusted if needed
SMALL: { width: 0.5, height: 0.5 }, // Half size
LARGE: { width: 2, height: 2 }, // Double size
} as const;
export const BARCODE_CONFIG = {
TYPE: 'CODE128',
TYPE: 'CODE39',
DIMENSIONS: { width: 2, height: 50 },
ALTERNATIVE_TYPES: ['CODE128', 'EAN13', 'UPC_A'] as const,
} as const;
export const PAPER_CONFIG = {

View File

@ -5,6 +5,8 @@ import { StepRepository, PrintHistoryRepository } from '../db/repositories';
import { Knex } from 'knex';
import logger from '../logger';
import { formatUtils } from './format-utils';
import { Command, CommandBuilder } from './printer-commands';
import { SerialCommandExecutor } from './command-executor';
import {
PRINTER_CONFIG,
FONT_SIZES,
@ -20,6 +22,7 @@ export class SerialPrinter implements PrinterInterface {
private printer: Printer<[]> | null = null;
private printHistoryRepo: PrintHistoryRepository;
private stepRepository: StepRepository;
private commandExecutor: SerialCommandExecutor | null = null;
constructor(printHistoryRepo: PrintHistoryRepository, stepRepo: StepRepository) {
this.printHistoryRepo = printHistoryRepo;
@ -32,6 +35,7 @@ export class SerialPrinter implements PrinterInterface {
this.device = new USB();
const options = { encoding: PRINTER_CONFIG.ENCODING };
this.printer = new Printer(this.device, options);
this.commandExecutor = new SerialCommandExecutor(this.printer);
logger.info('Printer device initialized successfully');
} catch (error) {
logger.error('Failed to initialize printer device:', error);
@ -80,58 +84,49 @@ export class SerialPrinter implements PrinterInterface {
}
async printTask(task: Task, db: Knex): Promise<void> {
if (!this.printer || !this.device) {
if (!this.commandExecutor) {
throw new Error('Printer not initialized');
}
const taskSteps = await this.getTaskSteps(db, task);
logger.info(`Printing task ${task.id} with ${taskSteps.length} steps`);
try {
await this.openPrinter();
// Print header with task ID as barcode
await this.printer
.font(FONT.DEFAULT)
.align(ALIGNMENT.CENTER)
.style(STYLE.BOLD)
.size(FONT_SIZES.LARGE.width, FONT_SIZES.LARGE.height)
.text(formatUtils.formatCheckbox(`Task: ${task.name}`))
.text(formatUtils.createBanner('=', PAPER_CONFIG.BANNER_LENGTH))
// .text('')
.align(ALIGNMENT.LEFT);
const commands = [
// Header with task name
CommandBuilder.header(`Task: ${task.name}`),
CommandBuilder.banner('=', PAPER_CONFIG.BANNER_LENGTH),
CommandBuilder.align(ALIGNMENT.LEFT),
// Print task ID as barcode
await this.printer
.barcode(task.id.toString(), BARCODE_CONFIG.TYPE, BARCODE_CONFIG.DIMENSIONS);
// .text('')
// .text('');
// Task ID as barcode
...CommandBuilder.taskBarcodeWithAlignment(task.id),
CommandBuilder.newline(),
CommandBuilder.newline(),
];
// Print steps
// Add steps
for (let i = 0; i < taskSteps.length; i++) {
const step = taskSteps[i];
const stepSection = formatUtils.formatSection(
formatUtils.formatStepHeader(step.name, i + 1, task.name, true),
step.instructions || 'No instructions provided',
'-'
logger.info(`Printing step ${step.id}: name="${step.name}", instructions="${step.instructions}"`);
commands.push(
CommandBuilder.fontSize(FONT_SIZES.NORMAL),
formatUtils.stepHeader(step.name, i + 1, task.name, true),
CommandBuilder.banner('-', PAPER_CONFIG.BANNER_LENGTH),
CommandBuilder.text(
step.instructions || 'No instructions provided',
),
CommandBuilder.newline(),
// ...CommandBuilder.stepBarcodeWithAlignment(step.id),
CommandBuilder.newline()
);
await this.printer
.size(FONT_SIZES.NORMAL.width, FONT_SIZES.NORMAL.height)
.text(stepSection[0])
.text(stepSection[1])
.size(FONT_SIZES.SMALL.width, FONT_SIZES.SMALL.height)
.text(stepSection[3]);
// .text('');
// Print step ID as barcode
await this.printer
.barcode(step.id.toString(), BARCODE_CONFIG.TYPE, BARCODE_CONFIG.DIMENSIONS);
// .text('');
}
await this.printer
.cut(true, PAPER_CONFIG.CUT_LINES);
commands.push(CommandBuilder.cut(true, PAPER_CONFIG.CUT_LINES));
await this.commandExecutor.executeCommands(commands);
await this.closePrinter();
logger.info(`Printed task ${task.id}`);
@ -148,10 +143,12 @@ export class SerialPrinter implements PrinterInterface {
}
async printStep(step: Step, db: Knex): Promise<void> {
if (!this.printer || !this.device) {
if (!this.commandExecutor) {
throw new Error('Printer not initialized');
}
logger.info(`Printing step ${step.id}: name="${step.name}", instructions="${step.instructions}"`);
try {
await this.openPrinter();
@ -159,30 +156,31 @@ export class SerialPrinter implements PrinterInterface {
const task = await this.stepRepository.findTaskById(step.id);
const stepNumber = await this.stepRepository.findStepNumber(step.id);
const stepSection = formatUtils.formatSection(
formatUtils.formatStepHeader(step.name, stepNumber, task?.name),
step.instructions || 'No instructions provided'
const headerText = formatUtils.getCheckboxText(formatUtils.stepHeader(step.name, stepNumber, task?.name));
const stepSection = formatUtils.section(
headerText,
step.instructions || 'No instructions provided',
'-'
);
await this.printer
.font(FONT.DEFAULT)
.align(ALIGNMENT.CENTER)
.style(STYLE.BOLD)
.size(FONT_SIZES.LARGE.width, FONT_SIZES.LARGE.height)
.text(stepSection[0])
.text(stepSection[1])
.text('')
.align(ALIGNMENT.LEFT)
.size(FONT_SIZES.NORMAL.width, FONT_SIZES.NORMAL.height)
.text(stepSection[3])
.text('')
.text('');
// Print step ID as barcode
await this.printer
.barcode(step.id.toString(), BARCODE_CONFIG.TYPE, BARCODE_CONFIG.DIMENSIONS)
.cut(true, PAPER_CONFIG.CUT_LINES);
const commands = [
CommandBuilder.font(FONT.DEFAULT),
CommandBuilder.align(ALIGNMENT.CENTER),
CommandBuilder.style(STYLE.BOLD),
CommandBuilder.fontSize(FONT_SIZES.NORMAL),
formatUtils.stepHeader(step.name, stepNumber, task?.name),
// CommandBuilder.text(headerText),
CommandBuilder.newline(),
CommandBuilder.align(ALIGNMENT.LEFT),
CommandBuilder.fontSize(FONT_SIZES.NORMAL),
CommandBuilder.text(step.instructions || 'No instructions provided'),
CommandBuilder.newline(),
CommandBuilder.newline(),
...CommandBuilder.stepBarcodeWithAlignment(step.id),
CommandBuilder.cut(true, PAPER_CONFIG.CUT_LINES),
];
await this.commandExecutor.executeCommands(commands);
await this.closePrinter();
logger.info(`Printed step ${step.id}`);

View File

@ -5,16 +5,27 @@ import { StepRepository, PrintHistoryRepository } from '../db/repositories';
import { Knex } from 'knex';
import logger from '../logger';
import { formatUtils } from './format-utils';
import { CommandBuilder } from './printer-commands';
import { TestCommandExecutor } from './command-executor';
import {
PAPER_CONFIG,
FONT_SIZES,
ALIGNMENT,
FONT,
STYLE,
} from './printer-constants';
export class TestPrinter implements Printer {
private readonly outputDir: string;
private printHistoryRepo: PrintHistoryRepository;
private stepRepository: StepRepository;
private commandExecutor: TestCommandExecutor;
constructor(printHistoryRepo: PrintHistoryRepository, stepRepo: StepRepository) {
this.outputDir = path.join(process.cwd(), 'test-output');
this.printHistoryRepo = printHistoryRepo;
this.stepRepository = stepRepo;
this.commandExecutor = new TestCommandExecutor();
this.ensureOutputDir();
}
@ -35,12 +46,41 @@ export class TestPrinter implements Printer {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = path.join(this.outputDir, `task-${task.id}-${timestamp}.txt`);
const content = [
...formatUtils.formatSection(`Task: ${task.name}`, ''),
...formatUtils.formatList(taskSteps.map((step, index) =>
formatUtils.formatStepHeader(step.name, index + 1, task.name, true)
), 0, '- '),
].join('\n') + '\n';
const commands = [
// Header with task name
CommandBuilder.header(`Task: ${task.name}`),
CommandBuilder.banner('=', PAPER_CONFIG.BANNER_LENGTH),
CommandBuilder.align(ALIGNMENT.LEFT),
// Task ID as barcode
...CommandBuilder.taskBarcodeWithAlignment(task.id),
CommandBuilder.newline(),
];
// Add steps
for (let i = 0; i < taskSteps.length; i++) {
const step = taskSteps[i];
logger.info(`Printing step ${step.id}: name="${step.name}", instructions="${step.instructions}"`);
const headerText = formatUtils.getCheckboxText(formatUtils.stepHeader(step.name, i + 1, task.name, true));
commands.push(
CommandBuilder.fontSize(FONT_SIZES.NORMAL),
CommandBuilder.text(headerText),
CommandBuilder.newline(),
...formatUtils.section(
'',
step.instructions || 'No instructions provided',
'-'
),
...CommandBuilder.stepBarcodeWithAlignment(step.id),
CommandBuilder.newline()
);
}
commands.push(CommandBuilder.cut(true, PAPER_CONFIG.CUT_LINES));
await this.commandExecutor.executeCommands(commands);
const content = this.commandExecutor.getOutput();
await fs.writeFile(filename, content);
logger.info(`Printed task ${task.id} to ${filename}`);
@ -61,10 +101,26 @@ export class TestPrinter implements Printer {
const task = await this.stepRepository.findTaskById(step.id);
const stepNumber = await this.stepRepository.findStepNumber(step.id);
const content = formatUtils.formatSection(
formatUtils.formatStepHeader(step.name, stepNumber, task?.name),
step.instructions
).join('\n') + '\n';
const headerText = formatUtils.getCheckboxText(formatUtils.stepHeader(step.name, stepNumber, task?.name));
const commands = [
CommandBuilder.font(FONT.DEFAULT),
CommandBuilder.align(ALIGNMENT.CENTER),
CommandBuilder.style(STYLE.BOLD),
CommandBuilder.fontSize(FONT_SIZES.NORMAL),
CommandBuilder.text(headerText),
CommandBuilder.newline(),
CommandBuilder.align(ALIGNMENT.LEFT),
CommandBuilder.fontSize(FONT_SIZES.NORMAL),
CommandBuilder.text(step.instructions || 'No instructions provided'),
CommandBuilder.newline(),
CommandBuilder.newline(),
...CommandBuilder.stepBarcodeWithAlignment(step.id),
CommandBuilder.cut(true, PAPER_CONFIG.CUT_LINES),
];
await this.commandExecutor.executeCommands(commands);
const content = this.commandExecutor.getOutput();
await fs.writeFile(filename, content);
logger.info(`Printed step ${step.id} to ${filename}`);

View File

@ -10,7 +10,8 @@
"baseUrl": ".",
"paths": {
"@shared/*": ["../shared/types/*"]
}
},
"outDir": "dist"
},
"include": ["src/**/*", "../shared/types/**/*"],
"exclude": ["node_modules", "dist"]