Docs consolidation + audit-log timezone fix + PTO-Exhausted sliding scale
Build and Push Docker Image / build (push) Successful in 49s

Documentation:
- Consolidate 6 doc surfaces to 3. Merge Unraid guide into README and
  fold durable mobile rules into AGENTS.md; remove README_UNRAID_INSTALL.md,
  MOBILE_RESPONSIVE.md, and the misplaced "CPAS Violation Tracker.md" vault note.
- Fix drift across README/AGENTS/in-app guide: clean-cycle roll-off model
  (not per-violation 90-day window), auth documented as shipped, dropped
  active_cpas_scores view, correct table count (8 tables/0 views), auth
  endpoints + ADMIN_PASSWORD in run commands, roadmap updated.

Fixes:
- Audit log / amendment timestamps: SQLite CURRENT_TIMESTAMP is UTC with no
  zone marker; new Date() parsed the space-separated form as local time,
  shifting entries +5h in Chicago/CDT. Pin the bare form to UTC before
  formatting in AuditLog.jsx and AmendViolationModal.jsx.
- Absence - PTO Exhausted: fixed 5 pts -> sliding scale 1-5.

Rebuild client bundle to include the above.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-10 17:07:51 -05:00
parent cd0064ce5b
commit aef4ae60cb
11 changed files with 206 additions and 779 deletions
+52 -13
View File
@@ -31,6 +31,9 @@ CPAS (Corrective & Progressive Accountability System) is an internal HR tool for
cpas/
├── Dockerfile # Multi-stage: builder (Node+React) → production (Alpine+Chromium)
├── server.js # All API routes + audit helper; single Express entry point
├── auth.js # scrypt hashing, sessions, bootstrap admin, requireAuth/requireAdmin
├── lib/
│ └── rolloff.js # Clean-cycle point roll-off model (computeStanding); SoT for scores
├── db/
│ ├── schema.sql # Base table + view definitions (CREATE TABLE IF NOT EXISTS)
│ └── database.js # DB connection, WAL/FK pragmas, auto-migrations on startup
@@ -52,7 +55,7 @@ cpas/
│ │ ├── components/ # One file per component; no barrel index
│ │ └── styles/
│ │ └── mobile.css # Media query overrides only; all other styles are inline
└── README.md / README_UNRAID_INSTALL.md
└── README.md # Install (local + Unraid), features, API reference, schema, roadmap
```
---
@@ -68,7 +71,11 @@ cpas/
| `violation_resolutions` | Soft-delete records (resolution type, reason, resolver) |
| `violation_amendments` | Field-level diff per amendment (old → new, changed_by, timestamp) |
| `audit_log` | Append-only write action log; never delete from this table |
| `active_cpas_scores` | VIEW: SUM(points) for negated=0 AND incident_date >= 90 days |
| `violation_types` | Persisted custom violation type definitions (UI-created; `type_key` prefixed `custom_`) |
| `users` | Auth accounts: username, scrypt `password_hash`, role (`admin`/`user`) |
| `sessions` | Bearer-token login sessions: token, user_id, expires_at (7-day TTL) |
> **Historical note:** an `active_cpas_scores` SQL view once computed a naive per-violation 90-day sum. It has been **dropped** (see `db/database.js`). CPAS standing is now computed in JavaScript by the clean-cycle roll-off model in `lib/rolloff.js` — see the CPAS Tier System section below.
### Immutable Fields (DO NOT allow amendment of these)
@@ -122,7 +129,15 @@ These thresholds are the authoritative values. Any feature touching tiers must u
The canonical tier logic lives in `client/src/components/CpasBadge.jsx` (`TIERS` array, `getTier()`, `getNextTier()`). Do not duplicate this logic elsewhere — import from `CpasBadge`.
The 90-day rolling window is computed by the `active_cpas_scores` view. This view is **dropped and recreated** in `database.js` on every startup to ensure it always reflects the correct `negated=0` filter.
### Point roll-off model (clean-cycle)
Active-point math lives in **`lib/rolloff.js`** (`computeStanding()`), not in SQL. The rule is a **clean-cycle roll-off**, NOT a per-violation 90-day window:
- Points retire only after the employee goes a full **90 consecutive days with no new non-negated violation**.
- **Any new violation resets that 90-day clock for the employee's entire balance.**
- Each completed clean cycle removes **5 points, oldest violation first**; each further roll-off requires another fresh 90-day clean stretch.
This is deliberately not "each violation expires 90 days after its own incident date" — one new violation pushes roll-off back for everything. Because the model is order-dependent, it is computed in JS and is the single source of truth for point totals. Do not reintroduce a SQL view or sum points client-side.
---
@@ -146,6 +161,21 @@ To add a new violation type: add an entry to `violationData` with a unique camel
---
## Authentication
All auth logic lives in **`auth.js`** (backend) and **`client/src/auth.js`** (token storage + axios interceptors). It is intentionally lightweight for a trusted-LAN deployment.
- **Password hashing**: scrypt, no external dependency. Stored format `scrypt$<saltHex>$<hashHex>`; verified with `crypto.timingSafeEqual`.
- **Sessions**: opaque 32-byte bearer tokens in the `sessions` table, **7-day TTL**. The client stores the token in `localStorage` (`cpas_token`) and an axios request interceptor attaches `Authorization: Bearer <token>`; a response interceptor clears the token and bounces to the login screen on any `401`.
- **Route guard**: `app.use('/api', auth.requireAuth)` protects every `/api/*` route registered after it. The only public routes are `GET /api/health` and `POST /api/auth/login`, registered *before* the guard. Admin-only routes add `auth.requireAdmin`.
- **Bootstrap admin**: created/re-synced every startup from `ADMIN_USERNAME` / `ADMIN_PASSWORD` env vars. Its password is owned by the environment — it cannot be changed from the UI (a UI change is overwritten on next restart). Rotate by changing `ADMIN_PASSWORD` and restarting. The Docker image ships a placeholder `changeme` that **must** be overridden at deploy time.
- **UI-managed users**: admins create/delete users and reset passwords via `/api/users*` (min 6-char passwords, `admin`/`user` roles). These accounts are unaffected by restarts.
- **Audit**: `login_success`, `login_failed`, `user_created`, `user_password_reset`, and `user_deleted` are all written to `audit_log`.
Do not weaken or bypass the global guard, and do not persist tokens anywhere but the existing `localStorage` key.
---
## Coding Standards
### Backend (`server.js`)
@@ -157,7 +187,7 @@ To add a new violation type: add an entry to `violationData` with a unique camel
2. Call `audit()` on success.
3. Return `{ error: '...' }` (not HTML) on all error paths.
- Group routes by resource (Employees, Violations, Dashboard, Audit). Match the existing comment banner style: `// ── Resource Name ───`.
- Do not add authentication middleware. This runs on a trusted internal network by design.
- **Auth is global, not per-route.** `app.use('/api', auth.requireAuth)` guards every `/api/*` route registered after it in `server.js`. The only public routes are `GET /api/health` and `POST /api/auth/login`, both registered *before* the guard. Admin-only routes add `auth.requireAdmin` as a second middleware. When adding a route, register it after the guard and let it inherit auth — do not add ad-hoc token checks. See the Authentication section below.
### Frontend (React)
@@ -166,9 +196,18 @@ To add a new violation type: add an entry to `violationData` with a unique camel
- **Toasts**: Use `useToast()` from `ToastProvider` for all user-facing feedback. Do not use `alert()` or `console.log` for user messages.
- **HTTP**: Use `axios` (already imported in form/modal components). Do not introduce `fetch` unless there is a compelling reason — keep it consistent.
- **State**: Prefer local `useState` over lifting state unless data is needed by multiple unrelated components. The only global context is `ToastProvider`.
- **Mobile**: Test layout at 768px breakpoint. Use the `isMobile` media query pattern already in `Dashboard.jsx` / `DashboardMobile.jsx`. Add breakpoint rules to `mobile.css`, not inline styles.
- **Component files**: One component per file. Name the file to match the export. No barrel `index.js` files.
### Mobile / responsive
The UI targets phones **375px and up** with no external CSS framework. Durable rules:
- **Breakpoints**: `768px` (phones → card layouts, stacked nav), `480px` (small phones → single-column stats), `375px` (minimum supported width). Tablet tweaks at `1024px`. Put breakpoint rules in `mobile.css`, not inline styles.
- **Layout switch**: at ≤768px the dashboard table swaps to a card layout — desktop renders `<table>` in `Dashboard.jsx`; mobile renders `DashboardMobile.jsx`. Gate with the `isMobile` media-query pattern.
- **Touch & iOS**: tap targets ≥ **44px**; form inputs use **16px** font to stop iOS focus-zoom; use `-webkit-overflow-scrolling: touch` for momentum scroll.
- **`useMediaQuery` hook**: currently defined inline in both `App.jsx` and `Dashboard.jsx` (see TODO #8 to extract to `src/hooks/useMediaQuery.js`). Reuse the extracted hook once it lands rather than copying it a third time.
- Test on a real device — Chrome DevTools device mode does not exercise touch behavior.
### Database Migrations
New columns are added via the auto-migration pattern in `database.js`. Do not modify `schema.sql` for columns that already exist in production. Instead:
@@ -191,7 +230,7 @@ Before adding a column or table, answer:
2. **Does it need audit trail?** If it tracks a change to an existing record, add a corresponding entry pattern to `violation_amendments` or `audit_log`.
3. **Is it soft-deletable?** Prefer `negated`/flag patterns over hard deletes for anything HR might need to reverse.
4. **Does it appear on PDFs?** Update `pdf/template.js` to reflect it. Test PDF output after schema changes.
5. **Does `active_cpas_scores` view need updating?** If the new column affects point calculations, update the view recreation block in `database.js`.
5. **Does it affect point calculations?** If so, update `computeStanding()` in `lib/rolloff.js` — that function is the single source of truth for active-point totals. (There is no longer a SQL scoring view.)
---
@@ -230,7 +269,7 @@ docker build -t cpas .
docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas
# Unraid: build → save → transfer → load → run with --pids-limit 2048
# See README_UNRAID_INSTALL.md for full Unraid instructions
# See the "Deploying on Unraid" section of README.md for full instructions
```
**Unraid PID limit is critical.** Chromium spawns many child processes for PDF generation. Always include `--pids-limit 2048` on Unraid containers or PDF generation will fail silently.
@@ -245,16 +284,16 @@ docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas
### Adding New Features
- **Score-affecting logic belongs in SQL**, not JavaScript. The `active_cpas_scores` view is the single source of truth for point totals. If you need a new score variant (e.g., 30-day window, category-filtered), add a new SQL view — don't compute it in a route handler.
- **Score-affecting logic belongs in `lib/rolloff.js`**, not scattered across route handlers or duplicated in SQL. `computeStanding()` is the single source of truth for point totals. If you need a new score variant (e.g., 30-day window, category-filtered), add it as an option/variant of the roll-off model — don't sum points ad hoc in a route.
- **New violation fields**: Add to `schema.sql` for fresh installs AND to the migration block in `database.js` for existing databases. Both are required.
- **Reporting features**: Future aggregate queries should join against `active_cpas_scores` view and `audit_log` rather than re-implementing point logic. Structure new API endpoints under `/api/reports/` namespace.
- **Notifications/alerts**: Any future alerting feature (email, Slack) should read from `audit_log` or query `active_cpas_scores` — do not add side effects directly into violation insert routes.
- **Authentication**: If auth is ever added, implement it as Express middleware applied globally before all `/api` routes. Do not add per-route auth checks. Session data (user identity) should flow into `performed_by` fields on audit and amendment records.
- **Reporting features**: Future aggregate queries should call `computeStanding()` (or join against `audit_log`) rather than re-implementing point logic. Structure new API endpoints under `/api/reports/` namespace, registered after the auth guard.
- **Notifications/alerts**: Any future alerting feature (email, Slack) should read from `audit_log` or call `computeStanding()` — do not add side effects directly into violation insert routes.
- **Auth is already implemented** (see the Authentication section). It is global middleware (`app.use('/api', auth.requireAuth)`) applied before all `/api` routes, exactly as this guidance recommends — do not add per-route auth checks. When wiring identity into records, pass the signed-in user into `performed_by`/`changed_by` fields on audit and amendment records.
- **Multi-tenant / multi-site**: The schema is single-tenant. If site isolation is ever needed, add a `site_id` foreign key to `employees` and `violations` as a migration column, then scope all queries with a `WHERE site_id = ?` clause.
### What NOT to Do
- Do not compute active CPAS scores in JavaScript by summing violations client-side. Always fetch from the `active_cpas_scores` view.
- Do not compute active CPAS scores by summing violation points client-side. Always fetch the server-computed standing (`/api/employees/:id/score`, `/api/dashboard`, etc.), which runs the clean-cycle model in `lib/rolloff.js`. A naive client-side sum ignores roll-off and will be wrong.
- Do not modify `prior_active_points` after a violation is inserted, EXCEPT via one of the two sanctioned paths: automatic recompute on a back-dated insert (`recomputeSnapshotsAfter()`), or manual admin backfill (`recomputeAllSnapshotsForEmployee()` behind `POST /api/employees/:id/recompute-snapshots` and the **↻ Backfill Snapshots** UI button). Both are audit-logged as `violation_snapshots_recomputed`. Never recompute snapshots on negate, restore, amend, or hard delete.
- Do not add columns to `audit_log`. It is append-only with a fixed schema.
- Do not add a framework or ORM. Raw SQL with prepared statements is intentional — it keeps the query behavior explicit and the dependency surface small.
@@ -295,7 +334,7 @@ Do not add implementation details to README — that belongs in code comments or
## Constraints & Non-Goals
- **No authentication.** This is intentional. The app runs on a trusted LAN. Do not add auth without explicit direction from the project owner.
- **Authentication is intentionally minimal.** A login gate, scrypt-hashed passwords, bearer-token sessions, and an admin/user role split exist (see the Authentication section) — but the app still runs on a trusted LAN. Do not layer on OAuth, SSO, password-reset email flows, or finer-grained permission tiers without explicit direction from the project owner. There is no CSRF/rate-limiting hardening by design; keep it deployed behind the LAN.
- **No external dependencies beyond what's in `package.json`.** Avoid introducing new npm packages unless they solve a clearly scoped problem. Prefer using existing stack capabilities.
- **No client-side routing library.** Navigation between Violation Form, Dashboard, and modals is handled via `App.jsx` state (`view` prop). Do not introduce React Router unless the navigation model meaningfully grows beyond 34 views.
- **No test suite currently.** If adding tests, use Vitest for frontend and a lightweight assertion library for backend routes. Do not add a full testing framework without discussion.
-141
View File
@@ -1,141 +0,0 @@
---
type: project
status: active
tags: [project, cpas, hr, internal-tool, docker, react, node, sqlite]
repo: https://git.alwisp.com/jason/cpas
started: 2026-03-06
updated: 2026-05-14
owner: Jason Stedwell
---
# CPAS Violation Tracker
Single-container Dockerized web app for **CPAS** (workforce standing / violation documentation) used internally at Message Point Media. Replaces ad-hoc paper / spreadsheet violation tracking with a structured, auditable system that produces signed PDF records and surfaces tier-escalation risk live.
> Repo: [git.alwisp.com/jason/cpas](https://git.alwisp.com/jason/cpas) · First commit: 2026-03-06 · Deployed on Unraid (`10.2.0.14:3001`)
## Stack
- **Frontend:** React + Vite (single-page app, dark theme, mobile-responsive at 375px+)
- **Backend:** Node.js + Express
- **DB:** SQLite via `better-sqlite3`, WAL mode, auto-migrations on boot
- **PDF:** Puppeteer + bundled Chromium (system install inside container)
- **Packaging:** One multi-stage Dockerfile — build React, install backend, bundle Chromium, ship as a single image
- **Runtime requirement on dev box:** Docker Desktop only. No host Node/npm needed.
## What it does
It manages **employee violations against the CPAS rubric** on a rolling 90-day window:
- **Company Dashboard** — every employee sorted by active CPAS points (highest risk first), with summary stat cards, tier badges, an "at-risk" flag (within 2 pts of next tier), and search + department filter.
- **Violation Form** — pick an employee, pick a violation type, see prior 90-day count inline; recidivist auto-escalation; pre-submit tier-crossing warning; context-aware fields; one-click PDF download on submit; optional employee acknowledgment block.
- **Employee Profile Modal** — full violation history, amendment count, edit employee, merge duplicate, negate/restore, hard delete, per-violation PDF, free-text notes/flags ("on PIP", "union member"), a per-violation 90-day **point expiration timeline** with projected tier drops, and a **↻ Backfill Snapshots** repair button that rebuilds the `prior_active_points` snapshot on every violation for that employee (use after a back-date if older PDFs show stale prior-point totals; audit-logged with reason `manual_backfill`).
- **Violation Amendment** — point value / type / incident date are immutable; non-scoring fields (location, witness, narrative, acknowledgment) are amendable with a field-level diff trail.
- **Audit Log** — append-only record of every write action (employee CRUD, violation logged/amended/negated/restored/deleted); filterable, paginated panel from the dashboard.
- **Toast notification system** — global success/error/warning/info, auto-dismiss with progress bar.
- **PDF generation** — Puppeteer, snapshot of prior active points at incident time, optional employee acknowledgment block on the signature page.
- **Stakeholder demo** — `/demo` static route with synthetic data, served before the SPA catch-all; no auth required.
## CPAS Tier System
| Points | Tier | Label |
| ------ | ---- | ----------------------- |
| 04 | 01 | Elite Standing |
| 59 | 1 | Realignment |
| 1014 | 2 | Administrative Lockdown |
| 1519 | 3 | Verification |
| 2024 | 4 | Risk Mitigation |
| 2529 | 5 | Final Decision |
| 30+ | 6 | Separation |
Scores are summed over a **rolling 90-day window**; negated violations excluded.
## Database
Six tables + one view:
- `employees` — id, name, department, supervisor, notes
- `violations` — full incident record, including `prior_active_points` snapshot
- `violation_resolutions` — soft-delete reason / details
- `violation_amendments` — field-level diff log (one row per changed field)
- `audit_log` — append-only system action log
- `active_cpas_scores` (view) — 90-day point sum per employee
Auto-migrations in `db/database.js` add new columns to existing DBs on startup — meaningful here because Jason runs this in production on Unraid, so the schema evolves without losing data.
## Deployment notes worth remembering
- **Unraid:** static IP on `br0` bridge (`10.2.0.14`), DB persisted at `/mnt/user/appdata/cpas/db/cpas.db`, WebUI on port 3001.
- **`--pids-limit 2048` is critical** — Puppeteer/Chromium spawns many processes for each PDF; Unraid's default cap silently kills PDF generation.
- **Volume:** `/mnt/user/appdata/cpas/db``/data`. Database survives rebuilds, image reloads, and container removal.
- Updates are a 3-step loop: `docker build` locally → `docker save | gzip` → SMB drop into appdata → `docker load` + restart container in Unraid GUI.
## Mobile
Responsive design targets 375px+ (iPhone SE and up). At ≤768px the dashboard table swaps to a card-based layout (`DashboardMobile.jsx`); the nav stacks; tap targets are ≥44px; form inputs use 16px font to prevent iOS focus zoom. No external CSS library — single `mobile.css` utility sheet + a `useMediaQuery` hook. Implementation details and testing checklist live in `MOBILE_RESPONSIVE.md`.
## Project structure
```
cpas/
├── Dockerfile # Multi-stage: React build + Express + Chromium
├── server.js # API + static SPA + /demo route
├── db/
│ ├── schema.sql # Tables + 90-day active score view
│ └── database.js # SQLite + auto-migrations
├── pdf/
│ ├── generator.js # Puppeteer
│ └── template.js # HTML PDF template
├── demo/ # /demo synthetic-data SPA
└── client/ # React + Vite frontend
└── src/
├── App.jsx # Root + footer (copyright, dev ticker, Gitea link)
├── data/
│ ├── violations.js # All CPAS violation definitions
│ └── departments.js
├── hooks/useEmployeeIntelligence.js
└── components/ # Dashboard, ViolationForm, EmployeeModal,
# AmendViolationModal, AuditLog, ToastProvider, etc.
```
## API surface (selected)
- `GET /api/health` — health + build version
- `GET /api/dashboard` — all employees with active points + violation counts
- `GET /api/employees/:id/expiration` — roll-off timeline with days remaining
- `POST /api/violations` — log violation (accepts `acknowledged_by`, `acknowledged_date`)
- `PATCH /api/violations/:id/amend` — non-scoring field amendment + diff log
- `PATCH /api/violations/:id/negated` / `/restore` — soft delete + restore
- `GET /api/violations/:id/pdf` — PDF download
- `GET /api/audit` — paginated audit log
## Status
Phase 8 of the public roadmap is complete (stakeholder demo + app footer with live dev ticker). Core HR documentation workflow is shipped: dashboard, violation entry, employee profile, amendments, audit log, expiration timeline, acknowledgment field, toast system, mobile layout.
## Notable open ideas (from roadmap)
**Quick wins (low effort):** column sort on dashboard, department multi-select filter, `N` keyboard shortcut for new violation, configurable at-risk threshold via env var, version.json injected at build time.
**Reporting:** violation trend chart (daily/weekly/monthly), department heat map, per-employee sparklines in profile modal.
**Workflow:** draft/pending violations before finalize, violation templates.
**Notifications:** tier-escalation alerts (email or in-app) on crossing into Tier 2+.
**Infra (higher effort):** multi-user auth with roles (currently runs on trusted internal LAN with none), scheduled DB backup, dark/light theme toggle.
## Review take
The app is well-scoped and visibly production-shaped, not a hobby project. Strong signals: append-only audit log, field-level amendment diffs, immutable scoring fields, `prior_active_points` snapshot baked into each violation row so historical PDFs stay accurate, and auto-migrations so the schema can evolve in place on a live Unraid deployment. The Unraid-specific install guide explicitly calls out `--pids-limit 2048` for Chromium, which is the kind of footgun that only gets documented after it's been hit in production — that detail alone tells you this is being actually used.
The single biggest gap relative to its current capability is **auth**. Everything else on the roadmap is incremental polish; multi-user auth with roles is the one change that meaningfully expands who can use the system safely. Worth pairing it with the proposed scheduled DB backup, since the moment more than one supervisor is writing, accidental damage gets more likely.
A secondary observation: the audit log + amendment diff infrastructure is already strong enough to support a "who changed what, when" view per employee — that's basically free reporting if surfaced in the profile modal as a timeline.
## Links
- Repo — [git.alwisp.com/jason/cpas](https://git.alwisp.com/jason/cpas)
- Local install guide — `README.md` (in repo)
- Unraid install guide — `README_UNRAID_INSTALL.md`
- Mobile implementation notes — `MOBILE_RESPONSIVE.md`
-314
View File
@@ -1,314 +0,0 @@
# Mobile-Responsive Implementation Guide
## Overview
This document describes the mobile-responsive updates implemented for the CPAS Tracker application. The design targets **standard phones (375px+ width)** with graceful degradation for smaller devices.
## Key Changes
### 1. **Responsive Utility Stylesheet** (`client/src/styles/mobile.css`)
A centralized CSS file providing:
- Media query breakpoints (768px, 480px)
- Touch-friendly tap targets (min 44px height)
- iOS input zoom prevention (16px font size)
- Utility classes for mobile layouts
- Card-based layout helpers
- Horizontal scroll containers
**Utility Classes:**
- `.hide-mobile` - Hide on screens ≤768px
- `.hide-tablet` - Hide on screens ≤1024px
- `.mobile-full-width` - Full width on mobile
- `.mobile-stack` - Stack flex items vertically
- `.mobile-scroll-x` - Enable horizontal scrolling
- `.mobile-card` - Card layout container
- `.mobile-sticky-top` - Sticky header positioning
### 2. **App Navigation** (`client/src/App.jsx`)
**Desktop Behavior:**
- Horizontal navigation bar
- Logo left, tabs center, docs button right
- Full tab labels displayed
**Mobile Behavior (768px):**
- Logo centered with full width
- Tabs stacked horizontally below logo
- Docs button positioned absolutely top-right
- Shortened tab labels ("📊 Dashboard" → "📊")
- Flexible padding (40px → 16px)
**Features:**
- `useMediaQuery()` hook for responsive detection
- Dynamic style injection via `<style>` tag
- Separate mobile CSS classes for targeted overrides
### 3. **Dashboard Layout** (`client/src/components/Dashboard.jsx`)
**Desktop View:**
- Traditional HTML table layout
- 7 columns (Index, Employee, Dept, Supervisor, Tier, Points, Violations)
- Horizontal scrolling for overflow
**Mobile View (768px):**
- Switches to card-based layout (DashboardMobile component)
- Each employee = one card with vertical data rows
- Touch-optimized tap targets
- Improved readability with larger fonts
**Mobile Stat Cards:**
- 2 columns on phones (480px+)
- 1 column on small phones (<480px)
- Reduced font sizes (28px → 24px)
- Compact padding
**Toolbar Adjustments:**
- Search input: 260px → 100% width
- Buttons stack vertically
- Full-width button styling
### 4. **Mobile Dashboard Component** (`client/src/components/DashboardMobile.jsx`)
A dedicated mobile-optimized employee card component:
**Card Structure:**
```
+--------------------------------+
| Employee Name [Button] |
| [At Risk Badge if applicable] |
|--------------------------------|
| Tier / Standing: [Badge] |
| Active Points: [Large #] |
| 90-Day Violations: [#] |
| Department: [Name] |
| Supervisor: [Name] |
+--------------------------------+
```
**Visual Features:**
- At-risk employees: Gold border + dark gold background
- Touch-friendly employee name buttons
- Color-coded point displays matching tier colors
- Compact spacing (12px margins)
- Subtle shadows for depth
### 5. **Responsive Breakpoints**
| Breakpoint | Target Devices | Layout Changes |
|------------|----------------|----------------|
| **1024px** | Tablets & below | Reduced padding, simplified nav |
| **768px** | Phones (landscape) | Card layouts, stacked navigation |
| **480px** | Small phones | Single-column stats, minimal spacing |
| **375px** | iPhone SE/6/7/8 | Optimized for minimum supported width |
### 6. **Touch Optimization**
**Tap Target Sizes:**
- All buttons: 44px minimum height (iOS/Android guidelines)
- Form inputs: 44px minimum height
- Navigation tabs: 44px touch area
**Typography:**
- Form inputs: 16px font size (prevents iOS zoom-in on focus)
- Readable body text: 13-14px
- Headers scale down appropriately
**Scrolling:**
- `-webkit-overflow-scrolling: touch` for smooth momentum scrolling
- Horizontal scroll on tables (desktop fallback)
- Vertical card scrolling on mobile
## Implementation Details
### Media Query Hook
```javascript
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) setMatches(media.matches);
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
```
**Usage:**
```javascript
const isMobile = useMediaQuery('(max-width: 768px)');
```
### Conditional Rendering Pattern
```javascript
{isMobile ? (
<DashboardMobile employees={filtered} onEmployeeClick={setSelectedId} />
) : (
<table style={s.table}>
{/* Desktop table layout */}
</table>
)}
```
### Dynamic Style Injection
```javascript
const mobileStyles = `
@media (max-width: 768px) {
.dashboard-wrap {
padding: 16px !important;
}
}
`;
return (
<>
<style>{mobileStyles}</style>
{/* Component JSX */}
</>
);
```
## Testing Checklist
### Desktop (>768px)
- [ ] Navigation displays horizontally
- [ ] Dashboard shows full table
- [ ] All columns visible
- [ ] Docs button on right side
- [ ] Full tab labels visible
### Tablet (768px - 1024px)
- [ ] Reduced padding maintains readability
- [ ] Stats cards wrap to 2-3 columns
- [ ] Table scrolls horizontally if needed
### Mobile Portrait (375px - 768px)
- [ ] Logo centered, tabs stacked
- [ ] Dashboard shows card layout
- [ ] Search input full width
- [ ] Buttons stack vertically
- [ ] Employee cards display all data
- [ ] Tap targets ≥44px
- [ ] No horizontal scroll required
### Small Mobile (<480px)
- [ ] Stat cards single column
- [ ] Text remains readable
- [ ] No layout breakage
- [ ] Footer wraps properly
### iOS-Specific
- [ ] Input focus doesn't zoom page (16px font)
- [ ] Smooth momentum scrolling
- [ ] Tap highlights work correctly
### Android-Specific
- [ ] Touch feedback visible
- [ ] Back button behavior correct
- [ ] Keyboard doesn't break layout
## Browser Support
- **Chrome/Edge:** 88+
- **Firefox:** 85+
- **Safari:** 14+
- **iOS Safari:** 14+
- **Chrome Android:** 88+
## Performance Considerations
1. **Media query hook** re-renders only on breakpoint changes, not continuous resize
2. **Card layout** renders fewer DOM elements than table on mobile
3. **CSS injection** happens once per component mount
4. **No external CSS libraries** (zero KB bundle increase)
## Future Enhancements
### Phase 2 (Optional)
- [ ] ViolationForm mobile optimization with multi-step wizard
- [ ] Modal responsive sizing and animations
- [ ] Swipe gestures for employee cards
- [ ] Pull-to-refresh on mobile
- [ ] Offline support with service workers
### Phase 3 (Advanced)
- [ ] Progressive Web App (PWA) capabilities
- [ ] Native app shell with Capacitor
- [ ] Biometric authentication
- [ ] Push notifications
## File Structure
```
client/src/
├── App.jsx # Updated with mobile nav
├── components/
│ ├── Dashboard.jsx # Responsive table/card switch
│ ├── DashboardMobile.jsx # Mobile card layout (NEW)
│ └── ... # Other components
└── styles/
└── mobile.css # Responsive utilities (NEW)
```
## Maintenance Notes
### Adding New Components
When creating new components, follow this pattern:
1. **Import mobile.css utility classes:**
```javascript
import '../styles/mobile.css';
```
2. **Use media query hook:**
```javascript
const isMobile = useMediaQuery('(max-width: 768px)');
```
3. **Provide mobile-specific styles:**
```javascript
const mobileStyles = `
@media (max-width: 768px) {
.my-component { /* mobile overrides */ }
}
`;
```
4. **Test on real devices** (Chrome DevTools is insufficient for touch testing)
### Debugging Tips
- Use Chrome DevTools Device Mode (Ctrl+Shift+M)
- Test on actual devices when possible
- Check console for media query match state
- Verify tap target sizes with Chrome Lighthouse audit
- Test keyboard behavior on Android
## Deployment
1. Merge `feature/mobile-responsive` into `master`
2. Rebuild client bundle: `cd client && npm run build`
3. Restart server
4. Clear browser cache (Ctrl+Shift+R)
5. Test on production URL with mobile devices
## Support
For issues or questions about mobile-responsive implementation:
- Check browser console for errors
- Verify `mobile.css` is loaded
- Test with different screen sizes
- Review media query breakpoints
---
**Branch:** `feature/mobile-responsive`
**Target Width:** 375px+ (standard phones)
**Last Updated:** March 8, 2026
**Maintainer:** Jason Stedwell
+85 -15
View File
@@ -19,22 +19,26 @@ Everything else — Node.js, npm, React build, Chromium for PDF — happens insi
# 1. Build the image (installs all deps + compiles React inside Docker)
docker build -t cpas .
# 2. Run it
# 2. Run it — set a strong admin password (the app requires login)
docker run -d --name cpas \
-p 3001:3001 \
-v cpas-data:/data \
-e ADMIN_USERNAME=admin \
-e ADMIN_PASSWORD='choose-a-strong-secret' \
cpas
# 3. Open
# http://localhost:3001
# 3. Open http://localhost:3001 and sign in as admin
```
> **The app requires login.** The admin account is created from `ADMIN_USERNAME` / `ADMIN_PASSWORD` on every start. The image ships a placeholder password (`changeme`) that **must** be overridden. To rotate the admin password later, change `ADMIN_PASSWORD` and restart. Additional users are created in-app by an admin (**Users** button, top-right).
## Update After Code Changes
```bash
docker build -t cpas .
docker stop cpas && docker rm cpas
docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas
docker run -d --name cpas -p 3001:3001 -v cpas-data:/data \
-e ADMIN_PASSWORD='choose-a-strong-secret' cpas
```
---
@@ -85,12 +89,16 @@ docker run \
-e HOST_CONTAINERNAME="cpas" \
-e 'PORT'='3001' \
-e 'DB_PATH'='/data/cpas.db' \
-e 'ADMIN_USERNAME'='admin' \
-e 'ADMIN_PASSWORD'='choose-a-strong-secret' \
-l net.unraid.docker.managed=dockerman \
-l net.unraid.docker.webui='http://[IP]:[PORT:3001]' \
-v '/mnt/user/appdata/cpas/db':'/data':'rw' \
cpas:latest
```
> **Set a strong `ADMIN_PASSWORD`.** The app requires login and the admin account is created/re-synced from these env vars on every start. The image ships a placeholder (`changeme`) that must be overridden. To rotate the admin password later, edit this variable and **Restart** the container. Additional users are created in-app by an admin (**Users** button).
Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned).
### Key settings explained
@@ -102,6 +110,8 @@ Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned).
| `--pids-limit` | `2048` | Required — Puppeteer/Chromium spawns many processes for PDF generation; default Unraid limit is too low and will cause PDF failures |
| `PORT` | `3001` | Express listen port inside the container |
| `DB_PATH` | `/data/cpas.db` | SQLite database path inside the container |
| `ADMIN_USERNAME` | `admin` | Bootstrap admin username (created/synced on startup) |
| `ADMIN_PASSWORD` | *(strong secret)* | Bootstrap admin password — **must** override the `changeme` default; re-syncs on every start |
| Volume | `/mnt/user/appdata/cpas/db``/data` | Persists the database across container restarts and rebuilds |
### Updating on Unraid
@@ -113,18 +123,62 @@ Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned).
> **Note:** The `--pids-limit 2048` flag is critical. Without it, Chromium hits Unraid's default PID limit and PDF generation silently fails or crashes the container.
### Unraid GUI alternative (Add Container form)
If you prefer the Unraid GUI over the CLI, load the image (`docker load < cpas-latest.tar.gz`), then **Docker → Add Container** (Advanced View on) and fill in the equivalent settings:
| Field | Value |
|---|---|
| Name | `cpas` |
| Repository | `cpas:latest` (local image; leave Docker Hub URL blank) |
| Network Type | `Bridge` (or a custom `br0` with a static IP) |
| WebUI | `http://[IP]:[PORT:3001]` |
| Port | Container `3001` → Host `3001` (TCP) |
| Path | Container `/data` → Host `/mnt/user/appdata/cpas/db` (Read/Write) |
| Variable | `PORT` = `3001` |
| Variable | `DB_PATH` = `/data/cpas.db` |
| Variable | `ADMIN_USERNAME` = `admin` |
| Variable | `ADMIN_PASSWORD` = *(a strong secret — override the default)* |
| Extra Parameters | `--pids-limit 2048` (Advanced View → *Extra Parameters*) |
### Verify
1. Docker tab → **cpas** shows a green icon → click it → **WebUI** (or open `http://[IP]:3001`).
2. A **login screen** appears — sign in with the `ADMIN_USERNAME` / `ADMIN_PASSWORD` you set.
3. Confirm **● API connected** in the header.
4. Health check (no login required): `http://[IP]:3001/api/health``{"status":"ok", ...}`.
### Troubleshooting
| Problem | Fix |
|---|---|
| Container won't start | Docker tab → container icon → **Logs** |
| Can't log in / forgot admin password | Edit Container → set a new `ADMIN_PASSWORD`**Restart**. It re-syncs from this variable on every start. |
| `ADMIN_PASSWORD not set` in logs | Add the `ADMIN_PASSWORD` variable and restart — without it the admin account is not created. |
| PDF generation fails / container crashes under load | Confirm `--pids-limit 2048` is set — Chromium spawns many processes per PDF. |
| Port 3001 conflict | Change the Host Port to `3002` in Edit Container. |
| "API unreachable" in UI | Confirm green icon, check Logs, try Restart. |
| DB permission error | Terminal: `chmod 755 /mnt/user/appdata/cpas/db` |
| Inspect DB directly | Terminal: `docker exec -it cpas sh`, then `sqlite3 /data/cpas.db ".tables"` |
---
## Stakeholder Demo
A standalone demo page with synthetic data is available at `/demo` (e.g. `http://localhost:3001/demo`).
It is served as a static route before the SPA catch-all and requires no authentication.
Useful for showing the app to stakeholders without exposing live employee data.
It is a static page (its own hardcoded data, no live API calls), served before the SPA catch-all, so it stays reachable without logging in. The live app and all `/api/*` data routes still require authentication — only this synthetic page is public. Useful for showing the app to stakeholders without exposing live employee data.
---
## Features
### Authentication & User Accounts
- **Login gate** on the entire app — every page and every `/api/*` route (except `/api/health` and the login endpoint) requires a valid session
- **Bearer-token sessions** with a 7-day lifetime; token stored client-side and attached to every request, cleared automatically on expiry
- **Bootstrap admin** created/synced on startup from the `ADMIN_USERNAME` / `ADMIN_PASSWORD` environment variables — rotate the password by changing the env var and restarting
- **Admin-managed users** — admins add/delete accounts and reset passwords from the in-app **Users** panel; two roles (`admin`, `user`)
- Passwords stored as scrypt hashes (no external auth dependency); logins and account changes are audit-logged
### Company Dashboard
- Live table of all employees sorted by active CPAS points (highest risk first)
- Summary stat cards: total employees, elite standing (0 pts), with active points, at-risk count, highest active score
@@ -157,7 +211,7 @@ Useful for showing the app to stakeholders without exposing live employee data.
- Hard delete option for data entry errors
- PDF download for any historical violation record
- **Notes & Flags** — free-text notes (e.g. "on PIP", "union member") with quick-add tag buttons; visible in the profile modal without affecting scoring
- **Point Expiration Timeline** — shows when each active violation rolls off the 90-day window, with a progress bar, days-remaining countdown, and projected tier-drop indicators
- **Point Roll-Off Timeline** — lists each upcoming roll-off event (5 points retire per completed 90-day clean cycle, oldest first), with a progress bar, days-remaining countdown anchored to the last violation date, projected post-roll-off score, and tier-drop indicators
- **↻ Backfill Snapshots** button (next to the Active Violations header) — manually rebuilds the `prior_active_points` snapshot on every violation for this employee. Use after back-dating a violation under older code, or any time a regenerated PDF shows stale prior-point totals. Audit-logged as `violation_snapshots_recomputed` with reason `manual_backfill`. See [Backfilling Prior-Points Snapshots](#backfilling-prior-points-snapshots) below.
- **Toast notifications** for all actions: negate, restore, delete, amend, PDF download, employee edit
@@ -200,7 +254,7 @@ Useful for showing the app to stakeholders without exposing live employee data.
| 2529 | 5 | Final Decision |
| 30+ | 6 | Separation |
Scores are computed over a **rolling 90-day window** (negated violations excluded).
Scores follow a **clean-cycle roll-off** (negated violations excluded): points retire only after the employee goes a full **90 consecutive days with no new violation**, and any new violation resets that clock for their entire balance. Each completed clean cycle drops **5 points, oldest first**. This is deliberately *not* a per-violation "expires 90 days after its own date" window — the math lives in [`lib/rolloff.js`](lib/rolloff.js) (`computeStanding`).
### PDF Generation
- Puppeteer + system Chromium (bundled in Docker image)
@@ -253,9 +307,18 @@ Response shape:
## API Reference
All routes except `GET /api/health` and `POST /api/auth/login` require a valid `Authorization: Bearer <token>` header. Routes marked **admin** additionally require an admin role.
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Health check (returns build SHA + timestamp) |
| GET | `/api/health` | Health check (public; returns build SHA + timestamp) |
| POST | `/api/auth/login` | Public — exchange username + password for a session token |
| GET | `/api/auth/me` | Current session's user |
| POST | `/api/auth/logout` | Invalidate the current session token |
| GET | `/api/users` | **admin** — list user accounts |
| POST | `/api/users` | **admin** — create a user (username, password ≥6 chars, role) |
| PATCH | `/api/users/:id/password` | **admin** — reset a user's password |
| DELETE | `/api/users/:id` | **admin** — delete a user (cannot delete self) |
| GET | `/api/employees` | List all employees (includes `notes`) |
| GET | `/api/employees/:id` | Single employee record |
| POST | `/api/employees` | Create or upsert employee |
@@ -291,9 +354,12 @@ cpas/
├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium
├── .dockerignore
├── package.json # Backend (Express) deps
├── server.js # API + static file server
├── server.js # API + static file server + global auth guard
├── auth.js # scrypt hashing, sessions, bootstrap admin, auth middleware
├── lib/
│ └── rolloff.js # Clean-cycle point roll-off model (computeStanding)
├── db/
│ ├── schema.sql # Tables + 90-day active score view
│ ├── schema.sql # Base table definitions (no views)
│ └── database.js # SQLite connection (better-sqlite3) + auto-migrations
├── pdf/
│ ├── generator.js # Puppeteer PDF generation
@@ -336,7 +402,7 @@ cpas/
## Database Schema
Six tables + one view:
Eight tables, no views. Active-point totals are computed in JavaScript by [`lib/rolloff.js`](lib/rolloff.js), not in SQL:
- **`employees`** — id, name, department, supervisor, notes
- **`violations`** — full incident record including `prior_active_points` snapshot, `acknowledged_by` / `acknowledged_date`, and `amount` (financial amount in question for chargeback/repayment)
@@ -344,7 +410,10 @@ Six tables + one view:
- **`violation_amendments`** — field-level diff log for violation edits; one row per changed field per amendment
- **`violation_types`** — persisted custom violation type definitions added via the UI; `type_key` is prefixed `custom_` to prevent collisions with hardcoded keys
- **`audit_log`** — append-only record of every write action (action, entity_type, entity_id, performed_by, details, timestamp)
- **`active_cpas_scores`** (view) — sum of points for non-negated violations in rolling 90 days, grouped by employee
- **`users`** — auth accounts: username, scrypt `password_hash`, role (`admin`/`user`)
- **`sessions`** — bearer-token login sessions: token, user_id, expires_at (7-day TTL)
> An `active_cpas_scores` SQL view existed in earlier versions; it was dropped once scoring moved to the order-dependent clean-cycle model in `lib/rolloff.js`.
---
@@ -397,6 +466,8 @@ Point values, violation type, and incident date are **immutable** after submissi
| 8 | App footer | Copyright (© Jason Stedwell), live dev ticker since first commit, Gitea repo icon+link |
| 9 | Custom violation types | Persisted user-defined violation types created from the form; `+ Add Type` / `Edit Type` UI; merged into the dropdown alongside hardcoded types; delete blocked when in use |
| 9 | Financial amount tracking | `amount` field on financial violations (chargeback, receipt negligence, custom types with the field enabled); stored on `violations`, rendered prominently on the PDF, amendable with audit-logged diffs |
| 10 | Authentication & user accounts | Login gate on all routes; scrypt-hashed passwords; 7-day bearer sessions; env-bootstrapped admin; admin-managed users with `admin`/`user` roles |
| 10 | Build version badge | Git SHA + build timestamp injected into `version.json` at `docker build`; surfaced in the footer and `/api/health` |
---
@@ -440,13 +511,12 @@ Effort ratings: 🟢 Low · 🟡 Medium · 🔴 High
|---------|--------|-------------|
| Tier escalation alerts | 🟡 | Email or in-app notification when an employee crosses into Tier 2+ so the relevant supervisor is automatically informed |
| At-risk threshold config | 🟢 | Make the "at-risk" warning threshold (currently hardcoded at 2 pts) configurable per deployment via an env var |
| version.json / build badge | 🟢 | Inject git SHA + build timestamp into a static file during `docker build`; surfaced in the footer and `/api/health` |
#### Infrastructure & Ops
| Feature | Effort | Description |
|---------|--------|-------------|
| Multi-user auth | 🔴 | Simple login with role-based access (admin, supervisor, read-only); currently the app runs on a trusted internal network with no auth |
| Expanded roles | 🟡 | Finer-grained access beyond the current `admin`/`user` split (e.g. supervisor-scoped or read-only roles) — builds on the shipped auth foundation |
| Automated DB backup | 🟡 | Cron job or Docker health hook to snapshot `/data/cpas.db` to a mounted backup volume or remote location on a schedule |
| Dark/light theme toggle | 🟡 | The UI is currently dark-only; a toggle would improve usability in bright environments |
-248
View File
@@ -1,248 +0,0 @@
# CPAS Violation Tracker — Unraid Installation Guide
> **Applies to:** Unraid 6.12+ | Single container | Port 3001
> **Host requirement:** Docker Desktop only — no Node.js needed
---
## Overview
The Docker image is fully self-contained. All dependencies and the compiled
React frontend are baked in during the build. You only need Docker Desktop
on your local machine to build and export the image.
---
## Part 1 — Build the Docker Image Locally
Open a terminal in the unzipped project folder:
```bash
docker build -t cpas-tracker .
```
This single command:
- Installs backend (Node/Express) dependencies
- Installs frontend (React/Vite) dependencies
- Compiles the React app
- Packages everything into one lean image
No npm, no Node.js required on your machine beyond Docker.
---
## Part 2 — Export the Image
```bash
docker save cpas-tracker | gzip > cpas-tracker.tar.gz
```
---
## Part 3 — Transfer to Unraid
### Option A — Windows SMB (Recommended, no terminal)
1. Open File Explorer → address bar → `\\[YOUR-UNRAID-IP]`
2. Open the **appdata** share
3. Create a folder named `cpas`
4. Drag `cpas-tracker.tar.gz` into `\\[YOUR-UNRAID-IP]\appdata\cpas\`
### Option B — SCP (Mac/Linux)
```bash
scp cpas-tracker.tar.gz root@[YOUR-UNRAID-IP]:/mnt/user/appdata/cpas/
```
---
## Part 4 — Prepare Unraid (Terminal — one time only)
1. In Unraid GUI → **Tools****Terminal**
2. Run:
```bash
mkdir -p /mnt/user/appdata/cpas/db
docker load < /mnt/user/appdata/cpas/cpas-tracker.tar.gz
```
Expected output:
```
Loaded image: cpas-tracker:latest
```
3. Close the terminal — no further terminal use needed for normal operation.
---
## Part 5 — Add the Container in Unraid GUI
### 5.1 Navigate to Docker tab
1. Click **Docker** in the top nav
2. Confirm Docker is **Enabled** (green toggle)
3. Scroll to bottom → click **Add Container**
4. Toggle **Advanced View ON** (top-right of the form)
---
### 5.2 Basic Settings
| Field | Value |
|---|---|
| **Name** | `cpas-tracker` |
| **Repository** | `cpas-tracker` |
| **Docker Hub URL** | *(leave blank — local image)* |
| **WebUI** | `http://[IP]:[PORT:3001]` |
| **Network Type** | `Bridge` |
| **Privileged** | `Off` |
| **Restart Policy** | `Unless Stopped` |
| **Console shell** | `bash` |
> Setting the WebUI field enables a one-click launch icon on the Docker tab.
---
### 5.3 Port Mapping
Click **Add another Path, Port, Variable, Label or Device**
| Setting | Value |
|---|---|
| Config Type | `Port` |
| Name | `Web UI` |
| Container Port | `3001` |
| Host Port | `3001` |
| Protocol | `TCP` |
---
### 5.4 Volume Mapping (Database Persistence)
Click **Add another Path, Port, Variable, Label or Device**
| Setting | Value |
|---|---|
| Config Type | `Path` |
| Name | `Database` |
| Container Path | `/data` |
| Host Path | `/mnt/user/appdata/cpas/db` |
| Access Mode | `Read/Write` |
> The SQLite database lives here and survives container restarts and image updates.
---
### 5.5 Environment Variables
Click **Add another Path, Port, Variable, Label or Device** for each:
**Variable 1 — Port**
| Setting | Value |
|---|---|
| Config Type | `Variable` |
| Name | `Port` |
| Key | `PORT` |
| Value | `3001` |
**Variable 2 — Database Path**
| Setting | Value |
|---|---|
| Config Type | `Variable` |
| Name | `Database Path` |
| Key | `DB_PATH` |
| Value | `/data/cpas.db` |
**Variable 3 — Admin Username**
| Setting | Value |
|---|---|
| Config Type | `Variable` |
| Name | `Admin Username` |
| Key | `ADMIN_USERNAME` |
| Value | `admin` |
**Variable 4 — Admin Password**
| Setting | Value |
|---|---|
| Config Type | `Variable` |
| Name | `Admin Password` |
| Key | `ADMIN_PASSWORD` |
| Value | *(set a strong password — do not leave the default)* |
> **Important:** The app requires login. The admin account is created from
> `ADMIN_USERNAME` / `ADMIN_PASSWORD` on every start. **Always set a strong
> `ADMIN_PASSWORD`** — the image ships with a placeholder (`changeme`) that must
> be overridden. To rotate the admin password later, change this value and
> **Restart** the container. Additional user accounts are created in-app by an
> admin (top-right **Users** button) and are not affected by restarts.
---
### 5.6 Apply
1. Click **Apply** at the bottom
2. Watch the progress log — wait for "Container started"
3. Click **Done**
---
## Part 6 — Verify
1. Docker tab → **cpas-tracker** should show a green icon
2. Click the container icon → **WebUI**
Or open: `http://[YOUR-UNRAID-IP]:3001`
3. A **login screen** appears. Sign in with the `ADMIN_USERNAME` / `ADMIN_PASSWORD`
you set in step 5.5.
4. Confirm **● API connected** appears in the header
5. Health check (no login required): `http://[YOUR-UNRAID-IP]:3001/api/health`
`{"status":"ok","timestamp":"..."}`
---
## Part 7 — Updating After Code Changes
### Locally:
```bash
docker build -t cpas-tracker .
docker save cpas-tracker | gzip > cpas-tracker.tar.gz
```
### Transfer to Unraid (same as Part 3)
### On Unraid — GUI only after first load:
1. Copy new tar.gz to Unraid (SMB drag-and-drop)
2. **Tools → Terminal**`docker load < /mnt/user/appdata/cpas/cpas-tracker.tar.gz`
3. **Docker tab** → click `cpas-tracker` icon → **Restart**
> Your database at `/mnt/user/appdata/cpas/db/cpas.db` is never touched during updates.
---
## Troubleshooting
| Problem | Fix |
|---|---|
| Container won't start | Docker tab → container icon → **Logs** |
| Can't log in / forgot admin password | Edit Container → set a new `ADMIN_PASSWORD`**Restart**. The admin password re-syncs from this variable on every start. |
| "ADMIN_PASSWORD not set" in logs | Add the `ADMIN_PASSWORD` variable (step 5.5) and restart — without it the admin account is not created. |
| Port 3001 conflict | Change Host Port to `3002` in Edit Container |
| "API unreachable" in UI | Confirm green icon, check Logs, try Restart |
| DB permission error | Terminal: `chmod 755 /mnt/user/appdata/cpas/db` |
| Inspect DB directly | Terminal: `docker exec -it cpas-tracker sh` then `sqlite3 /data/cpas.db ".tables"` |
---
## Quick Reference — Unraid Docker Tab Actions
| Action | Steps |
|---|---|
| Open app | Container icon → WebUI |
| View logs | Container icon → Logs |
| Restart | Container icon → Restart |
| Edit settings | Container icon → Edit |
| Stop/Start | Container icon → Stop / Start |
---
*CPAS Violation Tracker — Phase 1 | Message Point Media internal use*
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -9,7 +9,7 @@
html, body { margin: 0; padding: 0; height: 100%; }
#root { height: 100%; }
</style>
<script type="module" crossorigin src="/assets/index-C1XYfYmx.js"></script>
<script type="module" crossorigin src="/assets/index-DhWLw3RT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BoQf6yV_.css">
</head>
<body>
@@ -73,9 +73,13 @@ const s = {
},
};
// SQLite CURRENT_TIMESTAMP is UTC as "YYYY-MM-DD HH:MM:SS" with no zone marker;
// JS Date() would parse that space-separated form as local time and shift it by
// the viewer's UTC offset. Pin the bare SQLite form to UTC before formatting.
function fmtDt(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', { timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short' });
const utc = /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(iso) ? iso.replace(' ', 'T') + 'Z' : iso;
return new Date(utc).toLocaleString('en-US', { timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short' });
}
export default function AmendViolationModal({ violation, onClose, onSaved }) {
+6 -1
View File
@@ -85,9 +85,14 @@ const s = {
},
};
// SQLite CURRENT_TIMESTAMP returns UTC as "YYYY-MM-DD HH:MM:SS" with no zone
// marker. JS Date() parses that space-separated form as LOCAL time, shifting
// every entry by the viewer's UTC offset (the ~5h drift seen from Chicago/CDT).
// Detect the bare SQLite form and pin it to UTC before formatting.
function fmtDt(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
const utc = /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(iso) ? iso.replace(' ', 'T') + 'Z' : iso;
return new Date(utc).toLocaleString('en-US', {
timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short',
});
}
+11 -5
View File
@@ -112,7 +112,13 @@ Roles: **Admin** can manage users plus everything a User can do; **User** can vi
## How Scoring Works
Every violation carries a **point value** set at the time of submission. Points count toward an employee's score only within a **rolling 90-day window** — once a violation is older than 90 days it automatically drops off and the score recalculates.
Every violation carries a **point value** set at the time of submission. Points do **not** each expire on their own 90-day timer. Instead, CPAS uses a **clean-cycle roll-off**:
- An employee's points start retiring only after they go a full **90 consecutive days with no new violation**.
- **Any new violation resets that 90-day clock** — the whole balance stays put and the countdown starts over.
- Each completed 90-day clean stretch removes **5 points, oldest violation first**. Dropping another 5 requires another full 90 clean days.
In short: staying clean is what burns points down, and a single new incident pushes everyone's roll-off back to square one.
Negated (voided) violations are excluded from scoring immediately. Hard-deleted violations are removed from the record entirely.
@@ -172,13 +178,13 @@ Shows current tier badge, active points, and 90-day violation count.
#### Notes & Flags
Free-text field for HR context (e.g. "On PIP", "Union member", "Pending investigation", "FMLA"). Quick-add tag buttons pre-fill common statuses. Notes are visible to anyone who opens the profile but **do not affect CPAS scoring**. Edit inline; saves on blur.
#### Point Expiration Timeline
Visible when the employee has active points. Shows each active violation as a progress bar indicating how far through its 90-day window it is, days remaining until roll-off, and a **tier-drop indicator** for violations whose expiration would move the employee down a tier.
#### Point Roll-Off Timeline
Visible when the employee has active points. Lists each future roll-off event (Roll-off #1, #2, …) — how many points retire, a progress bar and days-remaining countdown to that event, the projected date, and a **tier-drop indicator** where a roll-off would move the employee down a tier. It reflects the clean-cycle model: the countdown is anchored to the last violation date, and logging a new violation resets it.
#### Violation History
Full record of all submissions — active, negated, and resolved.
- **Amend** — edit non-scoring fields (location, details, witness, submitted-by, incident time, acknowledged-by, acknowledged-date) on any active violation. Every change is logged as a field-level diff (old → new) with timestamp. Points, type, and incident date are immutable.
- **Amend** — edit non-scoring fields (location, details, witness, submitted-by, incident time, acknowledged-by, acknowledged-date, amount) on any active violation. Every change is logged as a field-level diff (old → new) with timestamp. Points, type, and incident date are immutable.
- **Negate** — soft-delete a violation with a resolution type and notes. The record is preserved in history; the points are immediately removed from the score. Fully reversible via **Restore**.
- **Hard delete** — permanent removal. Use only for genuine data entry errors.
- **PDF** — download the formal violation document for any historical record. If the violation has an employee acknowledgment on record, the PDF shows the filled-in name and date instead of blank signature lines.
@@ -234,7 +240,7 @@ The audit log is the authoritative record for compliance review. Nothing in it c
Amendments allow corrections to a violation's non-scoring fields without deleting and re-submitting, which would disrupt the audit trail and the prior-points snapshot.
**Amendable fields:** incident time, location, details, submitted-by, witness name, acknowledged-by, acknowledged-date.
**Amendable fields:** incident time, location, details, submitted-by, witness name, acknowledged-by, acknowledged-date, amount (dollar figure on financial violations).
**Immutable fields:** violation type, incident date, point value.
+1 -1
View File
@@ -19,7 +19,7 @@ export const violationData = {
},
pto_exhausted: {
name: 'Absence - PTO Exhausted', category: 'Attendance & Punctuality',
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
minPoints: 1, maxPoints: 5, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Any absence after PTO bank reaches zero'
},