commit 1b64c45c52b2d50038f4aba6ee08310e1d3b01c1 Author: MacPro Date: Sat Feb 28 09:27:26 2026 -0600 Initial commit for Start9 packaging diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5f67ba1 Binary files /dev/null and b/.DS_Store differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bad7881 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# Keep StartOS wrapper artifacts out of Docker build context +startos-wrapper/.git +startos-wrapper/docker-images +startos-wrapper/*.s9pk + +# Keep local app artifacts out of Docker build context +workout-planner/node_modules +workout-planner/.next +workout-planner/logs +workout-planner/.server.pid +workout-planner/prisma/*.db +workout-planner/prisma/data/*.db +workout-planner/.env +workout-planner/.env.local +workout-planner/.env.*.local + +# OS/editor junk +.DS_Store diff --git a/start9-example-packaging/.DS_Store b/start9-example-packaging/.DS_Store new file mode 100644 index 0000000..aaba589 Binary files /dev/null and b/start9-example-packaging/.DS_Store differ diff --git a/start9-example-packaging/0.3.5/.DS_Store b/start9-example-packaging/0.3.5/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/start9-example-packaging/0.3.5/.DS_Store differ diff --git a/start9-example-packaging/0.3.5/DEPLOY_035.md b/start9-example-packaging/0.3.5/DEPLOY_035.md new file mode 100644 index 0000000..011df61 --- /dev/null +++ b/start9-example-packaging/0.3.5/DEPLOY_035.md @@ -0,0 +1,33 @@ +# Deploy on StartOS 0.3.5 (Raspberry Pi) + +## 1) Build the package on your Mac +```bash +cd /Users/macpro/Projects/CRM +make -C start9/0.3.5 package +``` + +This creates: +- `start9/0.3.5/image.tar` +- `start9/0.3.5/ten31-database.s9pk` + +## 2) Upload package to StartOS +1. Open StartOS web UI. +2. Go to Services -> Sideload Package (or equivalent 0.3.5 menu). +3. Upload `ten31-database.s9pk`. +4. Install and start the service. + +## 3) First run +1. Open the service UI. +2. Create first admin account on the login screen. +3. In Settings, run one manual backup immediately. + +## 4) Data persistence contract +- App DB path: `/data/crm.db` +- Backup path: `/data/backups` + +Because these are in the persistent service volume, app restarts/upgrades do not erase data. + +## 5) Before any upgrade/migration +1. Run manual backup in-app. +2. Export fundraising state in-app. +3. Keep both files off-device as recovery copy. diff --git a/start9-example-packaging/0.3.5/Dockerfile b/start9-example-packaging/0.3.5/Dockerfile new file mode 100644 index 0000000..e9b8e27 --- /dev/null +++ b/start9-example-packaging/0.3.5/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + CRM_ENV=production \ + CRM_HOST=0.0.0.0 \ + CRM_PORT=8080 \ + CRM_DATA_DIR=/data \ + CRM_FRONTEND_DIR=/app/frontend + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/server.py /app/backend/server.py +COPY frontend /app/frontend +COPY start9/0.3.5/docker_entrypoint.sh /usr/local/bin/docker_entrypoint.sh +COPY start9/0.3.5/healthcheck.sh /usr/local/bin/healthcheck.sh + +RUN chmod +x /usr/local/bin/docker_entrypoint.sh /usr/local/bin/healthcheck.sh + +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/docker_entrypoint.sh"] diff --git a/start9-example-packaging/0.3.5/LICENSE b/start9-example-packaging/0.3.5/LICENSE new file mode 100644 index 0000000..5fbef5c --- /dev/null +++ b/start9-example-packaging/0.3.5/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ten31 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/start9-example-packaging/0.3.5/Makefile b/start9-example-packaging/0.3.5/Makefile new file mode 100644 index 0000000..d45b21c --- /dev/null +++ b/start9-example-packaging/0.3.5/Makefile @@ -0,0 +1,23 @@ +PKG_ID := ten-database +PKG_VERSION := 0.1.0.38 +REPO_ROOT := $(abspath ../..) +WRAPPER_DIR := $(CURDIR) +IMAGE_NAME := start9/$(PKG_ID)/main:$(PKG_VERSION) + +.PHONY: image-arm package verify clean + +image-arm: + docker buildx build --platform=linux/arm64 \ + -f $(WRAPPER_DIR)/Dockerfile \ + -t $(IMAGE_NAME) \ + -o type=docker,dest=$(WRAPPER_DIR)/image.tar \ + $(REPO_ROOT) + +package: image-arm + start-sdk pack + +verify: + start-sdk verify s9pk $(PKG_ID).s9pk + +clean: + rm -f $(WRAPPER_DIR)/image.tar $(WRAPPER_DIR)/$(PKG_ID).s9pk diff --git a/start9-example-packaging/0.3.5/README.md b/start9-example-packaging/0.3.5/README.md new file mode 100644 index 0000000..0bd28a8 --- /dev/null +++ b/start9-example-packaging/0.3.5/README.md @@ -0,0 +1,23 @@ +# Start9 Wrapper (0.3.5) + +This directory contains the StartOS 0.3.5 package wrapper for Ten31 Database. + +## Build prerequisites +- Docker with buildx +- `start-sdk` installed on build machine + +## Build package +```bash +cd /Users/macpro/Projects/CRM +make -C start9/0.3.5 package +``` + +## Verify package +```bash +cd /Users/macpro/Projects/CRM +make -C start9/0.3.5 verify +``` + +## Outputs +- `start9/0.3.5/image.tar` +- `start9/0.3.5/ten-database.s9pk` diff --git a/start9-example-packaging/0.3.5/docker_entrypoint.sh b/start9-example-packaging/0.3.5/docker_entrypoint.sh new file mode 100644 index 0000000..5d7d13c --- /dev/null +++ b/start9-example-packaging/0.3.5/docker_entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +DATA_DIR="${CRM_DATA_DIR:-/data}" +SECRET_FILE="$DATA_DIR/.crm-secret" + +mkdir -p "$DATA_DIR" "$DATA_DIR/backups" + +if [ -z "${CRM_SECRET_KEY:-}" ]; then + if [ -f "$SECRET_FILE" ]; then + CRM_SECRET_KEY="$(cat "$SECRET_FILE")" + else + CRM_SECRET_KEY="$(head -c 48 /dev/urandom | base64 | tr -d '\n' | tr '/+' 'ab')" + printf '%s' "$CRM_SECRET_KEY" > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + fi + export CRM_SECRET_KEY +fi + +exec python3 /app/backend/server.py diff --git a/start9-example-packaging/0.3.5/healthcheck.sh b/start9-example-packaging/0.3.5/healthcheck.sh new file mode 100644 index 0000000..d581c7c --- /dev/null +++ b/start9-example-packaging/0.3.5/healthcheck.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +PORT="${CRM_PORT:-8080}" +curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null diff --git a/start9-example-packaging/0.3.5/icon.png b/start9-example-packaging/0.3.5/icon.png new file mode 100644 index 0000000..9c491d0 Binary files /dev/null and b/start9-example-packaging/0.3.5/icon.png differ diff --git a/start9-example-packaging/0.3.5/icon.svg b/start9-example-packaging/0.3.5/icon.svg new file mode 100644 index 0000000..4cb3560 --- /dev/null +++ b/start9-example-packaging/0.3.5/icon.svg @@ -0,0 +1,43 @@ + + + + + + Ten31 + + + + + + + + + + \ No newline at end of file diff --git a/start9-example-packaging/0.3.5/image.tar b/start9-example-packaging/0.3.5/image.tar new file mode 100644 index 0000000..6051c12 Binary files /dev/null and b/start9-example-packaging/0.3.5/image.tar differ diff --git a/start9-example-packaging/0.3.5/instructions.md b/start9-example-packaging/0.3.5/instructions.md new file mode 100644 index 0000000..e87f6fc --- /dev/null +++ b/start9-example-packaging/0.3.5/instructions.md @@ -0,0 +1,24 @@ +# Ten31 Database (StartOS 0.3.5) + +## What this package does +- Runs Ten31 Database as a private web app. +- Persists all data under the StartOS service volume (`/data`). +- Exposes web UI/API on internal port `8080`. + +## First launch +1. Open the service UI from StartOS. +2. If this is a fresh install, create the first admin account from the login screen. +3. Go to Settings and run a manual backup once. + +## Airtable migration +1. Open Settings -> Migration. +2. Choose "Import from Airtable CSV". +3. Confirm row/column mappings before final import. + +## Data safety +- Database path in container: `/data/crm.db`. +- Backups path in container: `/data/backups/`. +- Before StartOS or package upgrades, run a backup and export from Settings. + +## Upgrade note +This 0.3.5 wrapper keeps app/runtime files separate from data volume so migration to a future 0.4 wrapper can preserve the same data directory layout. diff --git a/start9-example-packaging/0.3.5/manifest.yaml b/start9-example-packaging/0.3.5/manifest.yaml new file mode 100644 index 0000000..16dc242 --- /dev/null +++ b/start9-example-packaging/0.3.5/manifest.yaml @@ -0,0 +1,94 @@ +id: ten-database +title: Ten31 Database +version: 0.1.0.38 +release-notes: >- + Initial StartOS 0.3.5 package wrapper for Ten31 Database. +license: MIT +wrapper-repo: https://github.com/ten31/ten31-database-startos +upstream-repo: https://github.com/ten31/ten31-database +support-site: https://github.com/ten31/ten31-database/issues +marketing-site: https://ten31.vc +build: ["make image-arm"] + +description: + short: Self-hosted investor and fundraising database for Ten31. + long: >- + Ten31 Database is an Airtable-style investor CRM with fundraising grid, + communications logging, views, backups, and CSV import. This package stores + all runtime data in the service volume for upgrade-safe persistence. + +assets: + license: LICENSE + icon: icon.png + instructions: instructions.md + docker-images: image.tar + +main: + type: docker + image: main + entrypoint: docker_entrypoint.sh + args: [] + mounts: + main: /data + +health-checks: + main: + name: API health + success-message: CRM API is responding. + type: docker + image: main + entrypoint: healthcheck.sh + args: [] + inject: true + +config: ~ +dependencies: {} + +volumes: + main: + type: data + +interfaces: + main: + name: Web Interface + description: Browser UI and API for Ten31 Database. + tor-config: + port-mapping: + 80: "8080" + lan-config: + 443: + ssl: true + internal: 8080 + ui: true + protocols: [tcp, http, https] + +backup: + create: + type: docker + image: main + system: false + entrypoint: sh + args: + - -c + - | + set -eu + rm -rf /backup/* + cp -a /data/. /backup/ + mounts: + main: /data + BACKUP: /backup + restore: + type: docker + image: main + system: false + entrypoint: sh + args: + - -c + - | + set -eu + cp -a /backup/. /data/ + mounts: + main: /data + BACKUP: /backup + +actions: {} diff --git a/start9-example-packaging/0.3.5/ten-database.s9pk b/start9-example-packaging/0.3.5/ten-database.s9pk new file mode 100644 index 0000000..a0b1f04 Binary files /dev/null and b/start9-example-packaging/0.3.5/ten-database.s9pk differ diff --git a/start9-example-packaging/0.4/README.md b/start9-example-packaging/0.4/README.md new file mode 100644 index 0000000..fe182ea --- /dev/null +++ b/start9-example-packaging/0.4/README.md @@ -0,0 +1,9 @@ +# Start9 Wrapper (0.4 placeholder) + +This directory is reserved for the StartOS 0.4 package wrapper. + +Migration plan from 0.3.5: +1. Keep package id stable (`ten-database`) if StartOS migration path allows. +2. Keep mounted data directory contract unchanged (`/data/crm.db`, `/data/backups`). +3. Rebuild wrapper files against 0.4 packaging spec and verify with current start-sdk. +4. Test upgrade on a staging node using production backup restore before live cutover. diff --git a/start9-example-packaging/START9_PACKAGING_CHECKLIST.md b/start9-example-packaging/START9_PACKAGING_CHECKLIST.md new file mode 100644 index 0000000..54cf0b5 --- /dev/null +++ b/start9-example-packaging/START9_PACKAGING_CHECKLIST.md @@ -0,0 +1,115 @@ +# Start9 Packaging Checklist (0.3.5 style) + +This checklist is written for the StartOS 0.3.5 packaging flow used in this repo. +Use it as an indicative template for other projects, not a literal one-size-fits-all script. + +## 1) Required packaging scaffold (inside `start9//`) + +- `manifest.yaml` +- `Makefile` +- `Dockerfile` +- `docker_entrypoint.sh` +- `healthcheck.sh` +- `instructions.md` +- `icon.png` (and/or `icon.svg` if desired) + +Optional: +- `scripts/` for package-specific helper scripts +- prebuilt artifacts (`image.tar`, `.s9pk`) generated by packaging + +## 2) Project-specific values to change + +In `manifest.yaml`: +- `id` +- `title` +- `version` +- `description` +- `upstream-repo`, `support-site`, `marketing-site` +- `interfaces` (port, protocol, TLS, UI flags) +- `config` (runtime env/config options) +- `backup` mounts/commands +- `actions` (if you expose maintenance actions) + +In `Makefile`: +- package id/version variables +- image name/tag +- paths/targets used by `make ... package` + +In `Dockerfile`: +- base image +- runtime dependencies +- app copy paths +- entrypoint/cmd + +In scripts: +- read config/env from StartOS mount/env conventions +- write data only to mounted persistent directories + +## 3) What must exist outside `start9/` + +The wrapper is not fully standalone. It builds an image from your app source. + +For this CRM package specifically: +- `backend/server.py` +- `frontend/` (all static assets/UI) + +These are copied in Docker build steps. In other projects, these paths, filenames, and build inputs can be different. +This document is meant to show the pattern; each project must map to its own app layout. + +## 4) Data + persistence checklist + +- Persist DB/files under mounted data path (not container ephemeral path). +- Confirm backup/restore mounts and commands match the manifest volume names exactly. +- Verify restore can start app cleanly and preserve schema/data. + +## 5) Network/interface checklist + +- Confirm service listens on the internal container port expected by `manifest.yaml`. +- Confirm LAN interface protocol settings match actual service behavior (HTTP vs HTTPS/TCP). +- Confirm UI launches from StartOS Interfaces page without cert/protocol mismatch. + +## 6) Build + install flow + +1. Bump version in: + - `start9//manifest.yaml` + - `start9//Makefile` +2. Build package: + - `make -C start9/ package` +3. Install resulting `.s9pk` in StartOS. +4. Start service and check: + - health/logs + - UI launch + - persistence after restart + - backup/restore smoke test + +## 7) Reusable vs non-reusable parts + +Reusable: +- overall folder structure and file roles in `start9//` +- packaging workflow (`manifest` + `Makefile` + `Dockerfile` + scripts) + +Non-reusable without edits: +- app copy paths in Dockerfile +- app-specific env/config keys +- ports/interfaces/protocol values +- backup/restore commands tied to app data layout + +## 8) Planned migration path to StartOS 0.4 + +When 0.4 is ready for your deployment, use this approach: + +1. Keep 0.3.5 package stable as the production branch. +2. Create a parallel package folder for 0.4 (for example `start9/0.4/`). +3. Port wrapper files (`manifest`, `Makefile`, Docker packaging scripts) to the 0.4 schema/tooling. +4. Update interface/config/backup definitions to 0.4 expectations. +5. Build and install 0.4 package in a test server first. +6. Restore a real backup into 0.4 and validate: + - app starts + - UI works + - data integrity is preserved + - backup/restore still works +7. Only after successful validation, promote 0.4 package for primary use. + +Notes: +- Keep database path and backup format stable where possible to make migration low-risk. +- If schema changes are required, add explicit migration steps and rollback steps before production cutover. diff --git a/start9/0.3.5/DEPLOY_035.md b/start9/0.3.5/DEPLOY_035.md new file mode 100644 index 0000000..d90115e --- /dev/null +++ b/start9/0.3.5/DEPLOY_035.md @@ -0,0 +1,32 @@ +# Deploy on StartOS 0.3.5 (Raspberry Pi) + +## 1) Build package on your Mac +```bash +cd /Users/macpro/Projects/Workout-log +make -C start9/0.3.5 package +``` + +This creates: +- `start9/0.3.5/image.tar` +- `start9/0.3.5/workout-log.s9pk` + +## 2) Upload package to StartOS +1. Open the StartOS web UI. +2. Go to Services -> Sideload Package (0.3.5 menu naming may vary). +3. Upload `workout-log.s9pk`. +4. Install and start the service. + +## 3) First run +1. Open the service UI. +2. Log in with `admin@local` / `workout123`. +3. Change password and run one manual backup. + +## 4) Data persistence contract +- App DB path: `/data/app.db` + +Because this lives in the persistent service volume, restarts and wrapper upgrades should not erase data. + +## 5) Preparing for StartOS 0.4.0 migration +1. Run a StartOS backup before migration. +2. Keep `/data/app.db` contract unchanged in the future 0.4 wrapper. +3. Preserve package id (`workout-log`) when possible to simplify migration continuity. diff --git a/start9/0.3.5/Dockerfile b/start9/0.3.5/Dockerfile new file mode 100644 index 0000000..1f706d7 --- /dev/null +++ b/start9/0.3.5/Dockerfile @@ -0,0 +1,47 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache openssl + +COPY workout-planner/package.json workout-planner/package-lock.json ./ +RUN npm ci + +COPY workout-planner/ ./ +RUN npx prisma generate +RUN mkdir -p /tmp-seed \ + && DATABASE_URL=file:/tmp-seed/app.db npx prisma db push --skip-generate \ + && DATABASE_URL=file:/tmp-seed/app.db npm run db:seed +RUN npm run build + +FROM node:20-alpine AS runner + +WORKDIR /app + +RUN apk add --no-cache dumb-init curl openssl \ + && addgroup -S nodejs -g 1001 \ + && adduser -S nextjs -u 1001 -G nodejs + +ENV NODE_ENV=production \ + HOSTNAME=0.0.0.0 \ + PORT=3000 \ + WORKOUT_DATA_DIR=/data \ + WORKOUT_DB_PATH=/data/app.db \ + WORKOUT_SEED_DB_PATH=/app/prisma/data/app.db + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma +COPY --from=builder --chown=nextjs:nodejs /tmp-seed/app.db /app/prisma/data/app.db +COPY start9/0.3.5/docker_entrypoint.sh /usr/local/bin/docker_entrypoint.sh +COPY start9/0.3.5/healthcheck.sh /usr/local/bin/healthcheck.sh + +RUN chmod +x /usr/local/bin/docker_entrypoint.sh /usr/local/bin/healthcheck.sh \ + && mkdir -p /data \ + && chown -R nextjs:nodejs /app /data + +USER nextjs + +EXPOSE 3000 +ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker_entrypoint.sh"] diff --git a/start9/0.3.5/LICENSE b/start9/0.3.5/LICENSE new file mode 100644 index 0000000..013f845 --- /dev/null +++ b/start9/0.3.5/LICENSE @@ -0,0 +1,4 @@ +All rights reserved. + +This StartOS packaging wrapper and associated project materials are provided for +internal/private use unless otherwise licensed by the repository owner. diff --git a/start9/0.3.5/Makefile b/start9/0.3.5/Makefile new file mode 100644 index 0000000..3c4ded4 --- /dev/null +++ b/start9/0.3.5/Makefile @@ -0,0 +1,23 @@ +PKG_ID := workout-log +PKG_VERSION := 0.1.0.0 +REPO_ROOT := $(abspath ../..) +WRAPPER_DIR := $(CURDIR) +IMAGE_NAME := start9/$(PKG_ID)/main:$(PKG_VERSION) + +.PHONY: image-arm package verify clean + +image-arm: + docker buildx build --platform=linux/arm64 \ + -f $(WRAPPER_DIR)/Dockerfile \ + -t $(IMAGE_NAME) \ + -o type=docker,dest=$(WRAPPER_DIR)/image.tar \ + $(REPO_ROOT) + +package: image-arm + start-sdk pack + +verify: + start-sdk verify s9pk $(PKG_ID).s9pk + +clean: + rm -f $(WRAPPER_DIR)/image.tar $(WRAPPER_DIR)/$(PKG_ID).s9pk diff --git a/start9/0.3.5/README.md b/start9/0.3.5/README.md new file mode 100644 index 0000000..20edbda --- /dev/null +++ b/start9/0.3.5/README.md @@ -0,0 +1,23 @@ +# Start9 Wrapper (0.3.5) + +This directory contains the StartOS 0.3.5 package wrapper for Workout Log. + +## Build prerequisites +- Docker with buildx +- `start-sdk` installed on build machine + +## Build package +```bash +cd /Users/macpro/Projects/Workout-log +make -C start9/0.3.5 package +``` + +## Verify package +```bash +cd /Users/macpro/Projects/Workout-log +make -C start9/0.3.5 verify +``` + +## Outputs +- `start9/0.3.5/image.tar` +- `start9/0.3.5/workout-log.s9pk` diff --git a/start9/0.3.5/docker_entrypoint.sh b/start9/0.3.5/docker_entrypoint.sh new file mode 100755 index 0000000..1c055a7 --- /dev/null +++ b/start9/0.3.5/docker_entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +DATA_DIR="${WORKOUT_DATA_DIR:-/data}" +DB_PATH="${WORKOUT_DB_PATH:-$DATA_DIR/app.db}" +SEED_DB_PATH="${WORKOUT_SEED_DB_PATH:-/app/prisma/data/app.db}" + +mkdir -p "$DATA_DIR" + +if [ ! -f "$DB_PATH" ]; then + if [ -f "$SEED_DB_PATH" ]; then + cp "$SEED_DB_PATH" "$DB_PATH" + else + # Fallback if seed DB is unavailable. + touch "$DB_PATH" + fi +fi + +export DATABASE_URL="file:$DB_PATH" +export NODE_ENV="${NODE_ENV:-production}" +export HOSTNAME="${HOSTNAME:-0.0.0.0}" +export PORT="${PORT:-3000}" + +exec node /app/server.js diff --git a/start9/0.3.5/healthcheck.sh b/start9/0.3.5/healthcheck.sh new file mode 100755 index 0000000..34034ef --- /dev/null +++ b/start9/0.3.5/healthcheck.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +PORT="${PORT:-3000}" +curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null diff --git a/start9/0.3.5/icon.png b/start9/0.3.5/icon.png new file mode 100644 index 0000000..e5b2c66 Binary files /dev/null and b/start9/0.3.5/icon.png differ diff --git a/start9/0.3.5/image.tar b/start9/0.3.5/image.tar new file mode 100644 index 0000000..97414b5 Binary files /dev/null and b/start9/0.3.5/image.tar differ diff --git a/start9/0.3.5/instructions.md b/start9/0.3.5/instructions.md new file mode 100644 index 0000000..c238879 --- /dev/null +++ b/start9/0.3.5/instructions.md @@ -0,0 +1,21 @@ +# Workout Log (StartOS 0.3.5) + +## What this package does +- Runs Workout Log as a private web app. +- Persists all app data in the StartOS service volume (`/data`). +- Exposes web UI/API on internal port `3000`. + +## First launch +1. Open the service UI from StartOS. +2. Log in with default credentials: `admin@local` / `workout123`. +3. Immediately change the password from inside the app (recommended). +4. Run a manual StartOS backup after initial setup. + +## Data safety +- Database path in container: `/data/app.db`. +- All persistent state is kept under `/data` for upgrade-safe persistence. + +## Upgrade and migration note +This 0.3.5 wrapper keeps runtime data separate from app/runtime files using `/data`. +That makes migration to a future StartOS 0.4.0 wrapper straightforward as long as the +wrapper continues to use the same data path contract. diff --git a/start9/0.3.5/manifest.yaml b/start9/0.3.5/manifest.yaml new file mode 100644 index 0000000..be23ea6 --- /dev/null +++ b/start9/0.3.5/manifest.yaml @@ -0,0 +1,94 @@ +id: workout-log +title: Workout Log +version: 0.1.0.0 +release-notes: >- + Initial StartOS 0.3.5 package wrapper for Workout Log. +license: Proprietary +wrapper-repo: https://github.com/your-org/workout-log-startos +upstream-repo: https://github.com/your-org/workout-log +support-site: https://github.com/your-org/workout-log/issues +marketing-site: https://github.com/your-org/workout-log +build: ["make image-arm"] + +description: + short: Self-hosted workout planning and logging app. + long: >- + Workout Log is a self-hosted web app for planning workouts, logging sets, + tracking progress, and managing exercises. This package keeps runtime data in + the StartOS service volume for upgrade-safe persistence and future migration. + +assets: + license: LICENSE + icon: icon.png + instructions: instructions.md + docker-images: image.tar + +main: + type: docker + image: main + entrypoint: docker_entrypoint.sh + args: [] + mounts: + main: /data + +health-checks: + main: + name: API health + success-message: Workout Log API is responding. + type: docker + image: main + entrypoint: healthcheck.sh + args: [] + inject: true + +config: ~ +dependencies: {} + +volumes: + main: + type: data + +interfaces: + main: + name: Web Interface + description: Browser UI and API for Workout Log. + tor-config: + port-mapping: + 80: "3000" + lan-config: + 443: + ssl: true + internal: 3000 + ui: true + protocols: [tcp, http, https] + +backup: + create: + type: docker + image: main + system: false + entrypoint: sh + args: + - -c + - | + set -eu + rm -rf /backup/* + cp -a /data/. /backup/ + mounts: + main: /data + BACKUP: /backup + restore: + type: docker + image: main + system: false + entrypoint: sh + args: + - -c + - | + set -eu + cp -a /backup/. /data/ + mounts: + main: /data + BACKUP: /backup + +actions: {} diff --git a/start9/0.4/README.md b/start9/0.4/README.md new file mode 100644 index 0000000..eca0027 --- /dev/null +++ b/start9/0.4/README.md @@ -0,0 +1,10 @@ +# Start9 Wrapper (0.4.0 planning) + +This directory is reserved for the future StartOS 0.4.0 wrapper. + +Migration intent from 0.3.5: +- Keep package id as `workout-log` when StartOS 0.4.0 migration rules allow it. +- Preserve persistent data contract at `/data/app.db`. +- Reuse the same backup/restore semantics so migration is low-risk. + +When 0.4.0 is stable, adapt manifest/build interfaces to the 0.4 schema and validate with the relevant Start9 docs. diff --git a/start9/START9_PACKAGING_LOG.md b/start9/START9_PACKAGING_LOG.md new file mode 100644 index 0000000..c861262 --- /dev/null +++ b/start9/START9_PACKAGING_LOG.md @@ -0,0 +1,235 @@ +# Start9 Packaging Log: Workout Log (from example to working 0.3.5 wrapper) + +This file records exactly what was adapted from `start9-example-packaging` to package `workout-planner` for a Start9 server on StartOS `0.3.5` (Raspberry Pi / ARM64). + +It is written as reusable documentation so you can repeat this process for future apps. + +## 0) Goal and constraints + +- Target now: StartOS `0.3.5` on Raspberry Pi. +- Future target: StartOS `0.4.0` when stable. +- Priority: package should work now, while keeping data layout and wrapper structure easy to migrate later. + +## 1) What was reviewed first + +Reviewed the existing example wrapper in `start9-example-packaging/0.3.5`: + +- `manifest.yaml` +- `Makefile` +- `Dockerfile` +- `docker_entrypoint.sh` +- `healthcheck.sh` +- `instructions.md` +- `README.md` +- `DEPLOY_035.md` + +Then reviewed the app in `workout-planner`: + +- App Docker build/runtime behavior (`workout-planner/Dockerfile`) +- DB shape + config (`prisma/schema.prisma`, `DATABASE_URL` usage) +- Health endpoint (`/api/health`) +- Seed/default user (`prisma/seed.ts`) + +## 2) New Start9 wrapper scaffold created + +Created a **new** folder (separate from your example): + +- `start9/0.3.5/` +- `start9/0.4/` (planning placeholder) + +Files created in `start9/0.3.5`: + +- `manifest.yaml` +- `Dockerfile` +- `docker_entrypoint.sh` +- `healthcheck.sh` +- `Makefile` +- `instructions.md` +- `README.md` +- `DEPLOY_035.md` +- `LICENSE` +- `icon.png` (copied from app icon assets) + +Also created: + +- `start9/0.4/README.md` (migration intent notes) + +## 3) Key packaging design decisions + +### 3.1 Keep persistent data in `/data` + +To make upgrades/migrations safer, wrapper mounts Start9 volume at `/data` and stores SQLite DB at: + +- `/data/app.db` + +This is the most important continuity contract for migration to a future wrapper. + +### 3.2 Keep runtime app files out of persistent volume + +App code/binaries stay in image layers; only state goes in `/data`. This prevents app updates from overwriting user data. + +### 3.3 Health checks use app endpoint + +Wrapper health check calls: + +- `http://127.0.0.1:${PORT}/api/health` + +This checks both server and DB connectivity (as implemented by your app). + +### 3.4 Backup/restore copies whole `/data` + +Manifest backup/restore actions copy `/data` <-> `/backup` to align with Start9 expectations and keep DB safe. + +### 3.5 Future 0.4.0 migration posture + +Added explicit notes to preserve: + +- package id (`workout-log`) where compatible +- DB path contract (`/data/app.db`) +- backup semantics + +## 4) Runtime wiring added for first boot + +In `docker_entrypoint.sh`: + +- Ensures `/data` exists. +- Uses `/data/app.db` as `DATABASE_URL` target. +- If `/data/app.db` does not exist on first run, copies a seeded template DB from image (`/app/prisma/data/app.db`). +- Starts app with `node /app/server.js`. + +Why this matters: + +- First launch has a ready DB + default user. +- Subsequent restarts/upgrades keep existing `/data/app.db` untouched. + +## 5) Build issues discovered and fixes applied + +## Issue A: Prisma/OpenSSL runtime failure on ARM musl + +Observed error during ARM container validation: + +- Prisma engine expected OpenSSL compatibility; DB access failed. + +Fix applied in wrapper `Dockerfile`: + +- Install `openssl` in **builder** stage. +- Install `openssl` in **runner** stage. + +Result: + +- Prisma client loads correctly at runtime. + +## Issue B: First-run DB missing tables (`main.User` does not exist) + +Root cause: + +- Repo `.dockerignore` excludes local `.db` files, so no seeded DB was copied from source tree. + +Fix applied in wrapper `Dockerfile` build stage: + +1. Generate Prisma client. +2. Create temporary DB: `DATABASE_URL=file:/tmp-seed/app.db npx prisma db push --skip-generate` +3. Seed it: `DATABASE_URL=file:/tmp-seed/app.db npm run db:seed` +4. Copy seeded DB into image: `/app/prisma/data/app.db` + +Result: + +- First boot can copy seeded DB into `/data/app.db`. +- Health endpoint reports DB connected. + +## 6) Validation steps run + +Successfully validated: + +1. ARM image build from wrapper: + - `make -C start9/0.3.5 image-arm` + +2. Local smoke run from built image tar: + - `docker load -i start9/0.3.5/image.tar` + - run container and query `/api/health` + +Final smoke result: + +- HTTP `200` +- JSON contained `status: ok` and `database: connected` + +## 7) Why `start-sdk pack` failed on this machine + +`start-sdk pack` failed with: + +- `fatal: not a git repository (or any of the parent directories): .git` + +Meaning: + +- `start-sdk` expects to run from inside a Git repository so it can compute metadata (commit/hash). + +This is unrelated to your app logic; it is a packaging environment requirement. + +## 8) What “Step 1” means (plain English) + +When I said “initialize under git,” I meant: + +- The folder where you run `start-sdk pack` must be inside a Git repo. + +If your `Workout-log` folder is just a normal folder today, do this once: + +```bash +cd /Users/macpro/Projects/Workout-log +git init +git add . +git commit -m "Initial commit for Start9 packaging" +``` + +After that, this should work: + +```bash +make -C start9/0.3.5 package +``` + +You do **not** need your local dev server on port `3000` running for packaging. Packaging builds a Docker image and `.s9pk` artifact separately. + +## 9) Install flow on StartOS 0.3.5 + +1. Build package: + +```bash +cd /Users/macpro/Projects/Workout-log +make -C start9/0.3.5 package +``` + +2. In StartOS UI (0.3.5), sideload: + +- `start9/0.3.5/workout-log.s9pk` + +3. Install + start service. + +4. Open service UI and login: + +- `admin@local` / `workout123` + +5. Change password immediately. + +6. Run a manual backup. + +## 10) Reusable checklist for your next app + +Use this sequence next time: + +1. Copy known-good wrapper structure (`manifest`, `Dockerfile`, `entrypoint`, `healthcheck`, docs, makefile). +2. Define persistent data contract first (`/data/...`). +3. Ensure first boot initializes DB/schema (migration or seeded template). +4. Verify health endpoint checks both app + DB. +5. Build ARM image and smoke-test locally before Start9 sideload. +6. Ensure repo is a Git repo before `start-sdk pack`. +7. Document migration invariants for future StartOS versions (ID, DB path, backup format). + +## 11) Files to edit before publishing/distributing + +In `start9/0.3.5/manifest.yaml`, replace placeholder values: + +- `wrapper-repo` +- `upstream-repo` +- `support-site` +- `marketing-site` +- `license` (if you choose MIT or another license) + diff --git a/workout-planner/.env.example b/workout-planner/.env.example new file mode 100644 index 0000000..343c88f --- /dev/null +++ b/workout-planner/.env.example @@ -0,0 +1,5 @@ +# Database +DATABASE_URL=file:./data/app.db + +# API Keys +CLAUDE_API_KEY=your_claude_api_key_here diff --git a/workout-planner/.gitignore b/workout-planner/.gitignore new file mode 100644 index 0000000..6fb41b6 --- /dev/null +++ b/workout-planner/.gitignore @@ -0,0 +1,57 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Testing +coverage + +# Next.js +.next +out +dist +build + +# Production +.env.production.local +.env.local + +# Misc +.DS_Store +*.pem +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Debug +.vscode +.idea +*.swp +*.swo + +# Environment +.env +.env.*.local + +# Database +data/*.db +data/*.db-wal +data/*.db-shm +prisma/dev.db +prisma/dev.db-wal +prisma/dev.db-shm + +# IDE +.vscode/ +.idea/ +*.sublime-workspace +*.sublime-project + +# Server +logs/ +.server.pid + +# OS +Thumbs.db +.DS_Store diff --git a/workout-planner/Dockerfile b/workout-planner/Dockerfile new file mode 100644 index 0000000..0974cee --- /dev/null +++ b/workout-planner/Dockerfile @@ -0,0 +1,56 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./ + +# Install dependencies +RUN npm ci || npm install + +# Copy source code +COPY . . + +# Generate Prisma client +RUN npx prisma generate + +# Build Next.js +RUN npm run build + +# Runtime stage +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +# Install dumb-init for proper signal handling +RUN apk add --no-cache dumb-init + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nextjs -u 1001 + +# Copy built application from builder +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma + +# Create data directory for database +RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +# Use dumb-init to handle signals properly +ENTRYPOINT ["dumb-init", "--"] + +# Start the application +CMD ["node", "server.js"] diff --git a/workout-planner/ICON_FILES_INDEX.md b/workout-planner/ICON_FILES_INDEX.md new file mode 100644 index 0000000..a53ca91 --- /dev/null +++ b/workout-planner/ICON_FILES_INDEX.md @@ -0,0 +1,239 @@ +# Workout Planner PWA Icons - Complete Index + +## Quick Start + +To regenerate icons anytime: +```bash +node scripts/generate-icons.js +``` + +## Project Structure + +``` +workout-planner/ +├── scripts/ +│ ├── generate-icons.js # Main icon generation script +│ └── ICON_GENERATOR_README.md # Generation documentation +├── public/ +│ ├── manifest.json # Updated PWA manifest +│ └── icons/ +│ ├── icon-72x72.png # Small devices +│ ├── icon-96x96.png # Android Chrome smaller +│ ├── icon-128x128.png # Chrome Web Store +│ ├── icon-144x144.png # Windows medium tiles +│ ├── icon-152x152.png # iPad retina +│ ├── icon-192x192.png # Android standard +│ ├── icon-192x192-maskable.png # Android adaptive +│ ├── icon-384x384.png # Large displays +│ ├── icon-512x512.png # Install prompts +│ ├── icon-512x512-maskable.png # Android adaptive large +│ ├── favicon.svg # Vector favicon +│ ├── README.md # Icon directory docs +│ └── HEAD_INTEGRATION_EXAMPLE.html # HTML integration guide +├── ICON_GENERATION_SUMMARY.txt # Complete technical summary +└── ICON_FILES_INDEX.md # This file +``` + +## File Descriptions + +### Scripts + +**generate-icons.js** (6.8 KB) +- Node.js icon generator script +- No external dependencies (built-in modules only) +- Generates all PNG icons from scratch +- Also generates SVG favicon +- Executable: `node scripts/generate-icons.js` + +**ICON_GENERATOR_README.md** (3.5 KB) +- Complete technical documentation +- Usage instructions +- Customization guide +- Browser support information + +### Generated Icons (public/icons/) + +All PNG icons are RGBA format with proper PNG structure: + +**Standard Icons (8 files)** +| Size | File | Use Case | File Size | +|------|------|----------|-----------| +| 72x72 | icon-72x72.png | Small devices, Windows tiles | 252 B | +| 96x96 | icon-96x96.png | Android Chrome smaller displays | 482 B | +| 128x128 | icon-128x128.png | Chrome Web Store | 665 B | +| 144x144 | icon-144x144.png | Windows medium tiles | 972 B | +| 152x152 | icon-152x152.png | iPad retina displays | 833 B | +| 192x192 | icon-192x192.png | Android Chrome standard, home screen | 1.3 KB | +| 384x384 | icon-384x384.png | Larger displays, splash screens | 4.2 KB | +| 512x512 | icon-512x512.png | Install prompts, app stores | 7.2 KB | + +**Maskable Icons (2 files)** +| Size | File | Use Case | File Size | +|------|------|----------|-----------| +| 192x192 | icon-192x192-maskable.png | Android 8.0+ adaptive icons | 1.3 KB | +| 512x512 | icon-512x512-maskable.png | Android 8.0+ adaptive icons large | 7.2 KB | + +**Vector Icon (1 file)** +| Format | File | Use Case | File Size | +|--------|------|----------|-----------| +| SVG | favicon.svg | Modern browser favicon | 252 B | + +**Total Icons Size: ~25 KB** + +### Documentation + +**README.md** (3.8 KB, in public/icons/) +- Icon file descriptions +- Design specifications +- Integration instructions +- Browser support details +- Adaptive icon information + +**HEAD_INTEGRATION_EXAMPLE.html** (2.8 KB) +- Copy-paste HTML code for integration +- Complete HTML5 structure example +- PWA manifest links +- Meta tags for theming + +**ICON_GENERATION_SUMMARY.txt** (Large reference) +- Complete technical overview +- Implementation details +- Verification information +- Customization guide + +### Configuration Files + +**manifest.json** (Updated) +- PWA manifest with icon references +- Background color: #0A0A0A (dark luxury) +- Theme color: #FFFFFF (white) +- All 10 icon variants configured +- Icon purposes: "any" or "maskable" + +## Design Specifications + +**Color Scheme** +- Background: #0A0A0A (10, 10, 10 RGB) +- Foreground: #FFFFFF (255, 255, 255 RGB) +- Style: Clean, bold, geometric "W" letter + +**Size Coverage** +- Mobile devices: 72px - 152px +- Standard web: 192px - 384px +- Large displays: 512px +- Maskable variants: 192px, 512px +- Vector: SVG favicon + +**Platform Support** +- Chrome/Edge PWA +- Firefox PWA +- Safari iOS (Apple Touch Icon) +- Android app (with adaptive icons) +- Windows tiles +- Desktop browser tabs + +## HTML Integration + +Add to ``: + +```html + + + + + + +``` + +See `public/icons/HEAD_INTEGRATION_EXAMPLE.html` for complete HTML example. + +## Technical Details + +### PNG Generation Process + +1. **Rendering**: Draw white rectangles forming "W" on black background +2. **PNG Format**: Proper PNG structure with IHDR, IDAT, IEND chunks +3. **Compression**: zlib deflate algorithm for ~90% size reduction +4. **Integrity**: CRC32 checksums for data validation +5. **Output**: Valid PNG files verified with `file` command + +### Performance +- Generation time: < 1 second for all 11 icons +- Compression ratio: ~90% due to simple color scheme +- No external dependencies required + +## Customization + +### Change Colors + +1. Edit `/scripts/generate-icons.js`: + ```javascript + const BACKGROUND_COLOR = { r: 10, g: 10, b: 10 }; // Edit background + const TEXT_COLOR = { r: 255, g: 255, b: 255 }; // Edit foreground + ``` + +2. Regenerate: `node scripts/generate-icons.js` + +3. (Optional) Update `/public/manifest.json` theme colors + +### Change Design + +1. Edit `drawW()` function in `/scripts/generate-icons.js` +2. Modify bar positions, widths, or create new shapes +3. Regenerate: `node scripts/generate-icons.js` + +## File Locations (Absolute Paths) + +### Core Files +- Script: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/generate-icons.js` +- Icons: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/icons/` +- Manifest: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/manifest.json` + +### Documentation +- Generator README: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/ICON_GENERATOR_README.md` +- Icons README: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/icons/README.md` +- HTML Example: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/icons/HEAD_INTEGRATION_EXAMPLE.html` +- Summary: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/ICON_GENERATION_SUMMARY.txt` +- This Index: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/ICON_FILES_INDEX.md` + +## Verification + +All files have been verified: +- PNG signature correct (137 80 78 71...) +- Proper PNG structure (IHDR, IDAT, IEND chunks) +- CRC32 checksums validated +- Correct dimensions for all sizes +- File sizes optimized +- SVG favicon valid + +## Usage Summary + +| Task | Command | Location | +|------|---------|----------| +| Generate icons | `node scripts/generate-icons.js` | From project root | +| View icon docs | Read `public/icons/README.md` | - | +| Copy HTML code | See `public/icons/HEAD_INTEGRATION_EXAMPLE.html` | - | +| View generation docs | Read `scripts/ICON_GENERATOR_README.md` | - | +| Full reference | Read `ICON_GENERATION_SUMMARY.txt` | - | + +## Browser Compatibility + +- **Chrome/Edge**: Full PWA support with maskable icons +- **Firefox**: PWA installation support +- **Safari/iOS**: Apple Touch Icon support +- **Android**: Adaptive icon support with maskable variants +- **Windows**: App tile support +- **Desktop**: Favicon support across all browsers + +## Next Steps + +1. Add HTML manifest link to your pages +2. Test PWA installation on target platforms +3. Verify icon appearance on home screens +4. (Optional) Customize colors and design +5. Deploy to production + +--- + +Generated: 2026-02-17 +Project: Workout Planner - Dark Luxury Aesthetic diff --git a/workout-planner/ICON_GENERATION_SUMMARY.txt b/workout-planner/ICON_GENERATION_SUMMARY.txt new file mode 100644 index 0000000..6951e18 --- /dev/null +++ b/workout-planner/ICON_GENERATION_SUMMARY.txt @@ -0,0 +1,185 @@ +================================================================================ +PWA ICON GENERATION COMPLETE +================================================================================ + +Project: Workout Planner - Dark Luxury Aesthetic +Generated: 2026-02-17 + +================================================================================ +WHAT WAS CREATED +================================================================================ + +1. ICON GENERATION SCRIPT + Location: /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/generate-icons.js + + - Standalone Node.js script (no external dependencies needed) + - Uses only built-in Node.js modules: fs, zlib, path + - Generates valid PNG files with proper compression + - Uses PNG format (IHDR + IDAT chunks with zlib compression) + - Calculates CRC32 checksums for data integrity + +2. GENERATED PNG ICON FILES (11 files) + Location: /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/icons/ + + Standard Icons: + - icon-72x72.png (252 bytes) + - icon-96x96.png (482 bytes) + - icon-128x128.png (665 bytes) + - icon-144x144.png (972 bytes) + - icon-152x152.png (833 bytes) + - icon-192x192.png (1.3 KB) + - icon-384x384.png (4.2 KB) + - icon-512x512.png (7.2 KB) + + Maskable Icons (for Android adaptive icons): + - icon-192x192-maskable.png (1.3 KB) + - icon-512x512-maskable.png (7.2 KB) + + SVG Favicon: + - favicon.svg (252 bytes) + +3. DOCUMENTATION + Location: /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/ICON_GENERATOR_README.md + - Complete usage guide + - Technical implementation details + - Integration instructions for HTML + +4. UPDATED MANIFEST + Location: /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/manifest.json + - Updated to reference all generated icon sizes + - Set background_color to #0A0A0A (near-black) + - Set theme_color to #FFFFFF (white) + - Added maskable icon variants for Android + - Updated description for luxury gym aesthetic + +================================================================================ +DESIGN SPECIFICATIONS +================================================================================ + +Color Scheme: + - Background: #0A0A0A (near-black, luxury aesthetic) + - Text/Icon: #FFFFFF (white, high contrast) + +Icon Style: + - Stylized "W" letter (represents "Workout") + - Geometric design using rectangles/bars + - Clean, bold, modern appearance + - Scales proportionally across all sizes + +Supported Sizes: + - 72x72 (Chrome Web Store, Windows tiles small) + - 96x96 (Android Chrome) + - 128x128 (Chrome Web Store) + - 144x144 (Windows tiles medium) + - 152x152 (iPad retina) + - 192x192 (Android Chrome, standard) + - 384x384 (Large displays) + - 512x512 (Splash screens, install prompts) + +Maskable Variants: + - Android 8.0+ devices use "adaptive icons" + - Maskable icons can be displayed in various shapes + - 192x192 and 512x512 maskable variants provided + +================================================================================ +HOW TO USE +================================================================================ + +1. Run the Generator (if icons need to be regenerated): + $ node scripts/generate-icons.js + + Output: All PNG files generated in public/icons/ + +2. HTML Integration: + Add to section: + + + + + + + + +3. PWA Installation: + - Icons automatically used by browsers when adding to home screen + - Manifest.json defines icon purposes (any vs maskable) + - All common device sizes covered for optimal display + +================================================================================ +TECHNICAL IMPLEMENTATION +================================================================================ + +PNG Generation Process: +1. Allocate RGBA pixel buffer (size × size × 4 bytes) +2. Fill background with #0A0A0A +3. Draw stylized "W" using rectangle fill operations +4. Prepare PNG IHDR chunk (image header with metadata) +5. Prepare IDAT chunk: + - Add filter byte (0 = None) to each scanline + - Compress with zlib.deflateSync() +6. Calculate CRC32 checksum for data integrity +7. Combine signature + IHDR + IDAT + IEND chunks +8. Write to disk as valid PNG file + +Performance: +- Fast generation: All icons generated in <1 second +- Efficient compression: zlib reduces RGBA pixel data by ~90% +- Small file sizes: Total icon set = ~25 KB + +No External Dependencies: +- Uses only Node.js core modules +- No npm packages required +- Works in any Node.js environment + +================================================================================ +FILE LOCATIONS +================================================================================ + +Script: + /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/generate-icons.js + +Generated Icons: + /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/icons/ + +Manifest: + /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/manifest.json + +Documentation: + /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/ICON_GENERATOR_README.md + +================================================================================ +VERIFICATION +================================================================================ + +All PNG files verified as valid: + ✓ Correct PNG signature (137 80 78 71...) + ✓ IHDR chunk with correct dimensions + ✓ IDAT chunk with zlib-compressed data + ✓ IEND chunk for proper termination + ✓ CRC32 checksums validated + +File sizes optimized and verified with 'file' command. + +================================================================================ +NEXT STEPS +================================================================================ + +1. Test PWA installation on different browsers: + - Chrome/Edge: Install as app + - Firefox: Install as Progressive Web App + - Safari iOS: Add to Home Screen + - Android: Install with adaptive icon + +2. Verify icon display in: + - Address bar + - Home screen + - App launcher + - Task switcher + +3. Customize if needed: + - Edit BACKGROUND_COLOR in generate-icons.js + - Edit TEXT_COLOR for different colors + - Modify drawW() function for different letter design + - Re-run: node scripts/generate-icons.js + +================================================================================ diff --git a/workout-planner/README.md b/workout-planner/README.md new file mode 100644 index 0000000..3a7eda3 --- /dev/null +++ b/workout-planner/README.md @@ -0,0 +1,62 @@ +# Workout Planner + +A self-hosted workout planner and logger. Plan training cycles, log daily workouts, search your history, and get AI-powered suggestions over time. + +## Quick Start + +```bash +# Install dependencies +npm install + +# Set up the database +npx prisma db push + +# Seed with exercises and default user +npm run db:seed + +# Start development server +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser. + +**Default login:** `admin@local` / `workout123` + +## Access from Other Devices + +To access from your phone or iPad on the same network: + +```bash +npm run dev -- --hostname 0.0.0.0 +``` + +Then open `http://:3000` on your device. You can install it as a PWA (Add to Home Screen) for an app-like experience. + +## Docker Deployment + +```bash +docker compose up -d +``` + +## Tech Stack + +- **Next.js 14** (App Router) — full-stack TypeScript +- **SQLite** + Prisma ORM — local database, no separate server +- **Tailwind CSS** — mobile-first responsive design +- **PWA** — installable on any device + +## Project Structure + +``` +app/ + auth/login/ — Login page + main/ + dashboard/ — Quick stats and recent workouts + workouts/ — Workout history, logger, detail views + exercises/ — Exercise library + settings/ — Preferences and AI config + api/ — REST API routes +components/ — Reusable UI components +lib/ — Database queries, auth, utilities +prisma/ — Schema and seed data +``` diff --git a/workout-planner/app/api/auth/logout/route.ts b/workout-planner/app/api/auth/logout/route.ts new file mode 100644 index 0000000..04b3d7e --- /dev/null +++ b/workout-planner/app/api/auth/logout/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { prisma } from '@/lib/prisma'; + +export async function POST(_request: NextRequest) { + try { + const cookieStore = await cookies(); + const sessionCookie = cookieStore.get('sessionToken'); + + if (sessionCookie) { + // Delete the session from the database + await prisma.session.delete({ + where: { + token: sessionCookie.value, + }, + }).catch(() => { + // Session might not exist, that's ok + }); + + // Clear the cookie + cookieStore.delete('sessionToken'); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Logout error:', error); + return NextResponse.json( + { error: 'An error occurred during logout' }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/auth/route.ts b/workout-planner/app/api/auth/route.ts new file mode 100644 index 0000000..8200f24 --- /dev/null +++ b/workout-planner/app/api/auth/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { verifyPassword, createSession } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { email, password } = loginSchema.parse(body); + + // Look up user by email + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return NextResponse.json( + { error: 'Invalid email or password' }, + { status: 401 } + ); + } + + // Verify the password + const isValid = await verifyPassword(password, user.passwordHash); + + if (!isValid) { + return NextResponse.json( + { error: 'Invalid email or password' }, + { status: 401 } + ); + } + + // Create a session + const session = await createSession(user.id); + + // Set the session cookie + const response = NextResponse.json({ + success: true, + user: { + id: user.id, + email: user.email, + name: user.name, + }, + }); + + response.cookies.set('sessionToken', session.token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, // 30 days + path: '/', + }); + + return response; + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ); + } + + console.error('Login error:', error); + return NextResponse.json( + { error: 'An error occurred during login' }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/exercises/[id]/route.ts b/workout-planner/app/api/exercises/[id]/route.ts new file mode 100644 index 0000000..852244b --- /dev/null +++ b/workout-planner/app/api/exercises/[id]/route.ts @@ -0,0 +1,189 @@ +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +/** + * GET /api/exercises/[id] + * Get exercise with history + */ +export async function GET( + _request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const exercise = await prisma.exercise.findFirst({ + where: { + id: params.id, + userId: user.id, + }, + }); + + if (!exercise) { + return NextResponse.json({ error: "Exercise not found" }, { status: 404 }); + } + + // Get exercise history grouped by workout + const setLogs = await prisma.setLog.findMany({ + where: { + exerciseId: params.id, + workout: { + userId: user.id, + }, + }, + include: { + workout: { + select: { + id: true, + date: true, + name: true, + }, + }, + }, + orderBy: [ + { workout: { date: "desc" } }, + { setNumber: "asc" }, + ], + take: 100, + }); + + // Group by workout + const workoutMap = new Map(); + for (const log of setLogs) { + const key = log.workoutId; + if (!workoutMap.has(key)) { + workoutMap.set(key, { workout: log.workout, sets: [] }); + } + workoutMap.get(key)!.sets.push(log); + } + + const history = Array.from(workoutMap.values()).slice(0, 20); + + return NextResponse.json({ + exercise, + history, + }); + } catch (error) { + console.error("GET /api/exercises/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/exercises/[id] + * Edit exercise details + */ +const updateExerciseSchema = z.object({ + name: z.string().min(1).optional(), + type: z.string().min(1).optional(), + muscleGroups: z.array(z.string()).optional(), + description: z.string().optional(), + inputFields: z.array(z.string().min(1)).optional(), + defaultWeightUnit: z.string().nullable().optional(), +}); + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const exercise = await prisma.exercise.findFirst({ + where: { + id: params.id, + userId: user.id, + }, + }); + + if (!exercise) { + return NextResponse.json({ error: "Exercise not found" }, { status: 404 }); + } + + const body = await request.json(); + const validated = updateExerciseSchema.parse(body); + + const data: any = {}; + if (validated.name !== undefined) data.name = validated.name; + if (validated.type !== undefined) data.type = validated.type; + if (validated.description !== undefined) data.description = validated.description; + if (validated.muscleGroups !== undefined) + data.muscleGroups = JSON.stringify(validated.muscleGroups); + if (validated.inputFields !== undefined) + data.inputFields = JSON.stringify(validated.inputFields); + if (validated.defaultWeightUnit !== undefined) + data.defaultWeightUnit = validated.defaultWeightUnit; + + const updated = await prisma.exercise.update({ + where: { id: params.id }, + data, + }); + + return NextResponse.json(updated); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid data", details: error.errors }, + { status: 400 } + ); + } + console.error("PATCH /api/exercises/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/exercises/[id] + */ +export async function DELETE( + _request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const exercise = await prisma.exercise.findFirst({ + where: { + id: params.id, + userId: user.id, + }, + }); + + if (!exercise) { + return NextResponse.json({ error: "Exercise not found" }, { status: 404 }); + } + + await prisma.setLog.deleteMany({ + where: { exerciseId: params.id }, + }); + + await prisma.exercise.delete({ + where: { id: params.id }, + }); + + return NextResponse.json({ message: "Exercise deleted successfully" }); + } catch (error) { + console.error("DELETE /api/exercises/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/exercises/route.ts b/workout-planner/app/api/exercises/route.ts new file mode 100644 index 0000000..bc6aeb4 --- /dev/null +++ b/workout-planner/app/api/exercises/route.ts @@ -0,0 +1,124 @@ +import { getCurrentUser } from "@/lib/auth"; +import { getExercises, createExercise } from "@/lib/db/exercises"; +import { prisma } from "@/lib/prisma"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const CreateExerciseSchema = z.object({ + name: z.string().min(1, "Exercise name is required"), + type: z.string().min(1), + muscleGroups: z.array(z.string()).default([]), + description: z.string().optional(), + inputFields: z.array(z.string().min(1)).optional(), + defaultWeightUnit: z.string().nullable().optional(), +}); + +/** + * GET /api/exercises + */ +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const query = searchParams.get("q"); + + let exercises; + + if (query) { + exercises = await prisma.exercise.findMany({ + where: { + userId: user.id, + name: { contains: query }, + }, + orderBy: { name: "asc" }, + }); + } else { + exercises = await getExercises(user.id); + } + + return NextResponse.json(exercises); + } catch (error) { + console.error("GET /api/exercises error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * POST /api/exercises + */ +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const validated = CreateExerciseSchema.parse(body); + + const existing = await prisma.exercise.findUnique({ + where: { + userId_name: { + userId: user.id, + name: validated.name, + }, + }, + }); + + if (existing) { + return NextResponse.json( + { error: "Exercise already exists" }, + { status: 400 } + ); + } + + // Determine default inputFields based on type + let inputFields = validated.inputFields; + if (!inputFields) { + if (validated.type === "cardio") { + inputFields = ["sets", "duration", "calories"]; + } else { + inputFields = ["sets", "reps", "weight"]; + } + } + + // Kettlebell defaults to kg + let defaultWeightUnit = validated.defaultWeightUnit; + if (defaultWeightUnit === undefined && validated.type === "kettlebell") { + defaultWeightUnit = "kg"; + } + + const exercise = await createExercise({ + userId: user.id, + name: validated.name, + type: validated.type, + description: validated.description, + muscleGroups: JSON.stringify(validated.muscleGroups), + inputFields: JSON.stringify(inputFields), + defaultWeightUnit: defaultWeightUnit || null, + isCustom: true, + }); + + return NextResponse.json(exercise, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Validation error", details: error.errors }, + { status: 400 } + ); + } + + console.error("POST /api/exercises error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/health/route.ts b/workout-planner/app/api/health/route.ts new file mode 100644 index 0000000..fb1beb1 --- /dev/null +++ b/workout-planner/app/api/health/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +/** + * GET /api/health + * Health check endpoint — verifies both the server and database are operational. + * Used by StartOS health checks and Docker health checks. + * Excluded from auth middleware in middleware.ts. + */ +export async function GET() { + try { + // Verify database connectivity with a lightweight query + const userCount = await prisma.user.count(); + + return NextResponse.json({ + status: "ok", + timestamp: Date.now(), + database: "connected", + users: userCount, + }); + } catch (error) { + // Server is up but database is unreachable or corrupted + return NextResponse.json( + { + status: "error", + timestamp: Date.now(), + database: "disconnected", + error: error instanceof Error ? error.message : "Unknown database error", + }, + { status: 503 } + ); + } +} diff --git a/workout-planner/app/api/import/parse/route.ts b/workout-planner/app/api/import/parse/route.ts new file mode 100644 index 0000000..17d1f15 --- /dev/null +++ b/workout-planner/app/api/import/parse/route.ts @@ -0,0 +1,281 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// Exercise name mapping - CSV shorthand to DB names +const NAME_MAP: Record = { + "Ab Wheel": "Ab Wheel Rollout", + "BB Upright Row": "Upright Row", + "Ball Situp": "Exercise Ball Situp", + "Bench": "Bench Press", + "DB Lateral Raise": "Lateral Raise", + "Dip": "Dips (Chest)", + "Face Pull": "Face Pulls", + "SA Lat Pulldown": "Lat Pulldown", + "SL Calf Raise": "Calf Raise", + "BB Row": "Barbell Row", + "DB Row": "Dumbbell Row", + "GHD": "Glute ham developer", + "Hamstring DL": "Hamstring deadlift", + "BB Curl": "Barbell Curl", + "BB Hip Bridge": "Hip Thrust", + "Cable Trap": "Rear delt", + "Chinup (Narrow)": "Chinup", + "Chinup Negatives": "Chinup", + "Squat (Foot Elevated)": "Squat", + "Ball Bicep Curl": "Dumbbell Curl", + "KB Hip Flexor": "Hip Flexor", + "Hamstring Deadlift": "Hamstring deadlift", + "Shoulder Press": "Overhead Press", + "CoC": "Captains of Crush", + "Hex DL": "Hex Bar Deadlift", + "KB Extension": "Kettlebell Leg Extension", + "Ski": "SkiErg", +}; + +interface ParsedSet { + setNumber: number; + weight?: number; + weightUnit: string; + reps?: number; + notes?: string; +} + +interface ParsedExercise { + exerciseId: string; + exerciseName: string; + sets: ParsedSet[]; +} + +interface ParsedWorkout { + date: string; + exercises: ParsedExercise[]; +} + +interface ParseResponse { + workouts: ParsedWorkout[]; + unmapped: string[]; +} + +function parseCSV(content: string): Array> { + const lines = content.trim().split("\n"); + if (lines.length === 0) return []; + + // Parse header + const header = lines[0].split(",").map((h) => h.trim().toLowerCase()); + const rows = []; + + // Parse data rows + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + const values = line.split(",").map((v) => v.trim()); + const row: Record = {}; + + header.forEach((col, idx) => { + if (values[idx]) { + row[col] = values[idx]; + } + }); + + rows.push(row); + } + + return rows; +} + +function getVariationNote(originalName: string): string | null { + if (originalName.includes("Narrow")) return "narrow"; + if (originalName.includes("Negatives")) return "negatives"; + if (originalName.includes("Foot Elevated")) return "foot elevated"; + return null; +} + +function resolveExerciseName(csvName: string): string { + // Check if it's in the name map + if (NAME_MAP[csvName]) { + return NAME_MAP[csvName]; + } + // Return as-is for direct lookup + return csvName; +} + +// Parse dates like "1/27/2026" or "2026-01-27" into ISO date string +function parseDate(dateStr: string): string { + // Try M/D/YYYY format + const mdyMatch = dateStr.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (mdyMatch) { + const month = mdyMatch[1].padStart(2, "0"); + const day = mdyMatch[2].padStart(2, "0"); + const year = mdyMatch[3]; + return `${year}-${month}-${day}T12:00:00.000Z`; + } + // Try ISO format + if (dateStr.includes("-")) { + return new Date(dateStr + "T12:00:00.000Z").toISOString(); + } + // Fallback + return new Date(dateStr).toISOString(); +} + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file") as File; + + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + if (!file.name.endsWith(".csv")) { + return NextResponse.json( + { error: "File must be a CSV file" }, + { status: 400 } + ); + } + + const content = await file.text(); + const rows = parseCSV(content); + + if (rows.length === 0) { + return NextResponse.json( + { error: "CSV is empty or invalid format" }, + { status: 400 } + ); + } + + // Get all user exercises for matching + const exercises = await prisma.exercise.findMany({ + where: { userId: user.id }, + select: { id: true, name: true }, + }); + + // Build case-insensitive lookup map + const exerciseMap = new Map(); + for (const ex of exercises) { + exerciseMap.set(ex.name.toLowerCase(), ex.id); + } + + // Group rows by date + const workoutsByDate = new Map>>(); + + const unmappedExercises = new Set(); + + for (const row of rows) { + const date = row.date || row.date_str || row.workout_date || ""; + const exerciseName = row.exercise || row.exercise_name || ""; + + if (!date || !exerciseName) { + continue; + } + + if (!workoutsByDate.has(date)) { + workoutsByDate.set(date, []); + } + + workoutsByDate.get(date)!.push(row); + + // Check if exercise can be resolved + const resolvedName = resolveExerciseName(exerciseName); + const isKnown = exerciseMap.has(resolvedName.toLowerCase()); + if (!isKnown) { + unmappedExercises.add(exerciseName); + } + } + + // Build parsed workouts + const parsedWorkouts: ParsedWorkout[] = []; + + for (const [date, rowsForDate] of workoutsByDate) { + const exercisesMap = new Map< + string, + { + exerciseId: string; + exerciseName: string; + sets: ParsedSet[]; + } + >(); + + for (const row of rowsForDate) { + const csvExerciseName = row.exercise || row.exercise_name || ""; + const resolvedName = resolveExerciseName(csvExerciseName); + const exerciseId = + exerciseMap.get(resolvedName.toLowerCase()) || ""; + + if (!exerciseId) { + unmappedExercises.add(csvExerciseName); + continue; + } + + if (!exercisesMap.has(exerciseId)) { + exercisesMap.set(exerciseId, { + exerciseId, + exerciseName: resolvedName, + sets: [], + }); + } + + const exerciseData = exercisesMap.get(exerciseId)!; + const weight = row.weight ? parseFloat(row.weight) : undefined; + const reps = row.reps ? parseInt(row.reps, 10) : undefined; + let notes = row.notes || ""; + + // Detect weight unit from notes + let weightUnit = "lbs"; + if (notes.toLowerCase().includes("kg")) { + weightUnit = "kg"; + } + + // Add variation note if applicable + const variationNote = getVariationNote(csvExerciseName); + if (variationNote) { + notes = notes + ? `${notes} (${variationNote})` + : `(${variationNote})`; + } + + const setNumber = exerciseData.sets.length + 1; + + exerciseData.sets.push({ + setNumber, + weight, + weightUnit, + reps, + notes: notes || undefined, + }); + } + + const workoutExercises = Array.from(exercisesMap.values()); + if (workoutExercises.length > 0) { + parsedWorkouts.push({ + date: parseDate(date), + exercises: workoutExercises, + }); + } + } + + // Sort by date ascending (oldest first) + parsedWorkouts.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ); + + const response: ParseResponse = { + workouts: parsedWorkouts, + unmapped: Array.from(unmappedExercises), + }; + + return NextResponse.json(response); + } catch (error) { + console.error("CSV parsing error:", error); + return NextResponse.json( + { error: "Failed to parse CSV file" }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/preferences/route.ts b/workout-planner/app/api/preferences/route.ts new file mode 100644 index 0000000..66f4f0f --- /dev/null +++ b/workout-planner/app/api/preferences/route.ts @@ -0,0 +1,112 @@ +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const PreferencesSchema = z.object({ + theme: z.enum(["light", "dark", "system"]).optional(), + defaultWeightUnit: z.enum(["lbs", "kg"]).optional(), + enableClaudeAI: z.boolean().optional(), + claudeApiKey: z.string().optional(), +}); + +/** + * GET /api/preferences + * Get user preferences + */ +export async function GET(_request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let preferences = await prisma.userPreferences.findUnique({ + where: { userId: user.id }, + }); + + if (!preferences) { + // Create default preferences + preferences = await prisma.userPreferences.create({ + data: { + userId: user.id, + theme: "system", + defaultWeightUnit: "lbs", + defaultRestSeconds: 90, + enableClaudeAI: false, + }, + }); + } + + // Don't return API key in response + const { claudeApiKey, ...safePreferences } = preferences; + return NextResponse.json({ + ...safePreferences, + claudeApiKey: claudeApiKey ? "***" : undefined, + }); + } catch (error) { + console.error("GET /api/preferences error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * POST /api/preferences + * Update user preferences + */ +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const validated = PreferencesSchema.parse(body); + + // Get or create preferences + let preferences = await prisma.userPreferences.findUnique({ + where: { userId: user.id }, + }); + + if (!preferences) { + preferences = await prisma.userPreferences.create({ + data: { + userId: user.id, + ...validated, + }, + }); + } else { + preferences = await prisma.userPreferences.update({ + where: { userId: user.id }, + data: validated, + }); + } + + // Don't return API key in response + const { claudeApiKey, ...safePreferences } = preferences; + return NextResponse.json({ + ...safePreferences, + claudeApiKey: claudeApiKey ? "***" : undefined, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: "Validation error", + details: error.errors, + }, + { status: 400 } + ); + } + + console.error("POST /api/preferences error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/settings/import-db/route.ts b/workout-planner/app/api/settings/import-db/route.ts new file mode 100644 index 0000000..7bce0ca --- /dev/null +++ b/workout-planner/app/api/settings/import-db/route.ts @@ -0,0 +1,176 @@ +import { getCurrentUser } from "@/lib/auth"; +import { NextRequest, NextResponse } from "next/server"; +import { writeFile, copyFile, unlink } from "fs/promises"; +import { existsSync } from "fs"; +import path from "path"; +import { execSync } from "child_process"; + +/** + * POST /api/settings/import-db + * Upload a SQLite database file to replace the current one. + * Creates a backup of the existing DB before replacing. + * Validates the uploaded file is a valid SQLite database with the expected tables. + */ +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("database") as File | null; + + if (!file) { + return NextResponse.json( + { error: "No database file provided" }, + { status: 400 } + ); + } + + // Basic size check (SQLite DBs for this app should be under 100MB) + if (file.size > 100 * 1024 * 1024) { + return NextResponse.json( + { error: "File too large (max 100MB)" }, + { status: 400 } + ); + } + + // Read the uploaded file into a buffer + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + + // Check SQLite magic bytes (first 16 bytes should start with "SQLite format 3\0") + const magic = buffer.slice(0, 16).toString("ascii"); + if (!magic.startsWith("SQLite format 3")) { + return NextResponse.json( + { error: "Invalid file — not a SQLite database" }, + { status: 400 } + ); + } + + // Determine the database file path from DATABASE_URL + const dbUrl = process.env.DATABASE_URL || "file:./data/app.db"; + let dbPath: string; + if (dbUrl.startsWith("file:")) { + dbPath = dbUrl.replace("file:", ""); + // Handle relative paths + if (!path.isAbsolute(dbPath)) { + dbPath = path.resolve(process.cwd(), "prisma", dbPath.replace("./", "")); + } + } else { + dbPath = path.resolve(process.cwd(), "prisma", "data", "app.db"); + } + + // Write uploaded file to a temp location for validation + const tempPath = dbPath + ".upload-temp"; + await writeFile(tempPath, buffer); + + // Validate the uploaded DB has the expected tables + try { + const tables = execSync( + `sqlite3 "${tempPath}" "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"`, + { encoding: "utf-8", timeout: 10000 } + ).trim(); + + const tableList = tables.split("\n").map((t) => t.trim()); + const requiredTables = ["User", "Exercise", "Workout", "SetLog"]; + const missingTables = requiredTables.filter( + (t) => !tableList.includes(t) + ); + + if (missingTables.length > 0) { + await unlink(tempPath); + return NextResponse.json( + { + error: `Invalid database — missing tables: ${missingTables.join(", ")}. This doesn't look like a Workout Planner database.`, + }, + { status: 400 } + ); + } + + // Run integrity check + const integrity = execSync( + `sqlite3 "${tempPath}" "PRAGMA integrity_check;"`, + { encoding: "utf-8", timeout: 10000 } + ).trim(); + + if (integrity !== "ok") { + await unlink(tempPath); + return NextResponse.json( + { error: "Database integrity check failed — file may be corrupted" }, + { status: 400 } + ); + } + } catch (err) { + // Clean up temp file + if (existsSync(tempPath)) await unlink(tempPath); + return NextResponse.json( + { error: "Could not validate the uploaded database file" }, + { status: 400 } + ); + } + + // Get some stats from the uploaded DB for the response + let stats = { users: 0, exercises: 0, workouts: 0 }; + try { + const userCount = execSync( + `sqlite3 "${tempPath}" "SELECT COUNT(*) FROM User;"`, + { encoding: "utf-8" } + ).trim(); + const exerciseCount = execSync( + `sqlite3 "${tempPath}" "SELECT COUNT(*) FROM Exercise;"`, + { encoding: "utf-8" } + ).trim(); + const workoutCount = execSync( + `sqlite3 "${tempPath}" "SELECT COUNT(*) FROM Workout;"`, + { encoding: "utf-8" } + ).trim(); + stats = { + users: parseInt(userCount) || 0, + exercises: parseInt(exerciseCount) || 0, + workouts: parseInt(workoutCount) || 0, + }; + } catch { + // Stats are optional, continue anyway + } + + // Back up the current database + const backupPath = dbPath + ".backup-" + Date.now(); + if (existsSync(dbPath)) { + await copyFile(dbPath, backupPath); + } + + // Replace the current database with the uploaded one + await copyFile(tempPath, dbPath); + + // Also remove WAL/SHM files if they exist (SQLite journal files) + for (const ext of ["-wal", "-shm", "-journal"]) { + const journalPath = dbPath + ext; + if (existsSync(journalPath)) { + await unlink(journalPath); + } + } + + // Clean up temp file + await unlink(tempPath); + + return NextResponse.json({ + success: true, + message: "Database imported successfully. Please refresh the page.", + stats, + backup: path.basename(backupPath), + }); + } catch (error) { + console.error("Database import error:", error); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "An error occurred during import", + }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/workouts/[id]/route.ts b/workout-planner/app/api/workouts/[id]/route.ts new file mode 100644 index 0000000..1c0aa8c --- /dev/null +++ b/workout-planner/app/api/workouts/[id]/route.ts @@ -0,0 +1,239 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma, getCaloriesBurned, setCaloriesBurned } from "@/lib/prisma"; +import { z } from "zod"; + +// GET: Get workout by ID +export async function GET( + _request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const workout = await prisma.workout.findUnique({ + where: { id: params.id }, + include: { + setLogs: { + include: { + exercise: true, + }, + orderBy: { + setNumber: "asc", + }, + }, + }, + }); + + if (!workout) { + return NextResponse.json({ error: "Workout not found" }, { status: 404 }); + } + + if (workout.userId !== user.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + // Prisma client doesn't know about caloriesBurned — fetch via raw SQL + const caloriesBurned = await getCaloriesBurned(workout.id); + + return NextResponse.json({ ...workout, caloriesBurned }); + } catch (error) { + console.error("Failed to fetch workout:", error); + return NextResponse.json( + { error: "Failed to fetch workout" }, + { status: 500 } + ); + } +} + +// PATCH: Update workout — supports metadata-only or full update with sets +const setSchema = z.object({ + exerciseId: z.string().min(1), + setNumber: z.number().int().positive(), + reps: z.number().int().positive().optional().nullable(), + weight: z.number().optional().nullable(), + weightUnit: z.string().default("lbs"), + rpe: z.number().int().min(1).max(10).optional().nullable(), + notes: z.string().optional().nullable(), +}); + +const updateWorkoutSchema = z.object({ + name: z.string().optional(), + notes: z.string().optional().nullable(), + date: z.string().optional(), // ISO date string + durationMinutes: z.number().int().positive().optional().nullable(), + difficulty: z.number().int().min(1).max(10).optional().nullable(), + caloriesBurned: z.number().int().positive().optional().nullable(), + sets: z.array(setSchema).optional(), // if provided, replaces all sets +}); + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const workout = await prisma.workout.findUnique({ + where: { id: params.id }, + select: { userId: true }, + }); + + if (!workout) { + return NextResponse.json({ error: "Workout not found" }, { status: 404 }); + } + + if (workout.userId !== user.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const body = await request.json(); + const validated = updateWorkoutSchema.parse(body); + + // Extract caloriesBurned separately — handled via raw SQL + const caloriesValue = validated.caloriesBurned; + const hasCaloriesUpdate = validated.caloriesBurned !== undefined; + + // Build the Prisma-compatible workout update data (no caloriesBurned) + const workoutData: Record = {}; + if (validated.name !== undefined) workoutData.name = validated.name; + if (validated.notes !== undefined) workoutData.notes = validated.notes || null; + if (validated.date !== undefined) workoutData.date = new Date(validated.date); + if (validated.durationMinutes !== undefined) + workoutData.durationMinutes = validated.durationMinutes; + if (validated.difficulty !== undefined) + workoutData.difficulty = validated.difficulty; + + // If sets are provided, do a full replace inside a transaction + if (validated.sets) { + const result = await prisma.$transaction(async (tx) => { + // Update workout metadata (without caloriesBurned) + if (Object.keys(workoutData).length > 0) { + await tx.workout.update({ where: { id: params.id }, data: workoutData }); + } + + // Delete all existing sets + await tx.setLog.deleteMany({ + where: { workoutId: params.id }, + }); + + // Create new sets + if (validated.sets!.length > 0) { + await tx.setLog.createMany({ + data: validated.sets!.map((set) => ({ + workoutId: params.id, + exerciseId: set.exerciseId, + setNumber: set.setNumber, + reps: set.reps ?? undefined, + weight: set.weight ?? undefined, + weightUnit: set.weightUnit, + rpe: set.rpe ?? undefined, + notes: set.notes ?? undefined, + } as any)), + }); + } + + // Return full updated workout + return tx.workout.findUnique({ + where: { id: params.id }, + include: { + setLogs: { + include: { exercise: true }, + orderBy: { setNumber: "asc" }, + }, + }, + }); + }); + + // Update caloriesBurned via raw SQL (outside transaction since Prisma doesn't know this column) + if (hasCaloriesUpdate) { + await setCaloriesBurned(params.id, caloriesValue ?? null); + } + const calories = await getCaloriesBurned(params.id); + + return NextResponse.json({ ...result, caloriesBurned: calories }); + } + + // Metadata-only update + if (Object.keys(workoutData).length > 0) { + await prisma.workout.update({ + where: { id: params.id }, + data: workoutData, + }); + } + + // Update caloriesBurned via raw SQL + if (hasCaloriesUpdate) { + await setCaloriesBurned(params.id, caloriesValue ?? null); + } + + const updated = await prisma.workout.findUnique({ + where: { id: params.id }, + include: { + setLogs: { + include: { exercise: true }, + orderBy: { setNumber: "asc" }, + }, + }, + }); + const calories = await getCaloriesBurned(params.id); + + return NextResponse.json({ ...updated, caloriesBurned: calories }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid data", details: error.errors }, + { status: 400 } + ); + } + console.error("Failed to update workout:", error); + return NextResponse.json( + { error: "Failed to update workout" }, + { status: 500 } + ); + } +} + +// DELETE: Delete workout +export async function DELETE( + _request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const workout = await prisma.workout.findUnique({ + where: { id: params.id }, + select: { userId: true }, + }); + + if (!workout) { + return NextResponse.json({ error: "Workout not found" }, { status: 404 }); + } + + if (workout.userId !== user.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + await prisma.workout.delete({ + where: { id: params.id }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to delete workout:", error); + return NextResponse.json( + { error: "Failed to delete workout" }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/workouts/[id]/sets/route.ts b/workout-planner/app/api/workouts/[id]/sets/route.ts new file mode 100644 index 0000000..164db4d --- /dev/null +++ b/workout-planner/app/api/workouts/[id]/sets/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +const addSetsSchema = z.object({ + exerciseId: z.string().min(1), + sets: z.array( + z.object({ + setNumber: z.number().int().positive(), + reps: z.number().int().positive().optional(), + weight: z.number().optional(), + weightUnit: z.string().default("lbs"), + rpe: z.number().int().min(1).max(10).optional(), + durationSeconds: z.number().int().positive().optional(), + distance: z.number().positive().optional(), + distanceUnit: z.string().optional(), + calories: z.number().int().positive().optional(), + notes: z.string().optional(), + }) + ), +}); + +// POST: Add an exercise's sets to an existing workout +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const workout = await prisma.workout.findUnique({ + where: { id: params.id }, + select: { userId: true }, + }); + + if (!workout) { + return NextResponse.json({ error: "Workout not found" }, { status: 404 }); + } + + if (workout.userId !== user.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const body = await request.json(); + const validated = addSetsSchema.parse(body); + + // Delete existing sets for this exercise in this workout (replace mode) + await prisma.setLog.deleteMany({ + where: { + workoutId: params.id, + exerciseId: validated.exerciseId, + }, + }); + + // Create new sets + await prisma.setLog.createMany({ + data: validated.sets.map((set) => ({ + workoutId: params.id, + exerciseId: validated.exerciseId, + setNumber: set.setNumber, + reps: set.reps, + weight: set.weight, + weightUnit: set.weightUnit, + rpe: set.rpe, + durationSeconds: set.durationSeconds, + distance: set.distance, + distanceUnit: set.distanceUnit, + calories: set.calories, + notes: set.notes, + } as any)), + }); + + // Return updated workout + const updated = await prisma.workout.findUnique({ + where: { id: params.id }, + include: { + setLogs: { + include: { exercise: true }, + orderBy: { setNumber: "asc" }, + }, + }, + }); + + return NextResponse.json(updated, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid data", details: error.errors }, + { status: 400 } + ); + } + console.error("Failed to add sets:", error); + return NextResponse.json( + { error: "Failed to add sets" }, + { status: 500 } + ); + } +} + +// DELETE: Remove all sets for a specific exercise from a workout +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const workout = await prisma.workout.findUnique({ + where: { id: params.id }, + select: { userId: true }, + }); + + if (!workout) { + return NextResponse.json({ error: "Workout not found" }, { status: 404 }); + } + + if (workout.userId !== user.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const exerciseId = searchParams.get("exerciseId"); + + if (!exerciseId) { + return NextResponse.json( + { error: "exerciseId query param required" }, + { status: 400 } + ); + } + + await prisma.setLog.deleteMany({ + where: { + workoutId: params.id, + exerciseId, + }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to delete sets:", error); + return NextResponse.json( + { error: "Failed to delete sets" }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/workouts/import/route.ts b/workout-planner/app/api/workouts/import/route.ts new file mode 100644 index 0000000..e792975 --- /dev/null +++ b/workout-planner/app/api/workouts/import/route.ts @@ -0,0 +1,216 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +const importSchema = z.object({ + images: z.array(z.string()).min(1, "At least one image is required"), +}); + +const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages"; + +const SYSTEM_PROMPT = `You are analyzing photos of handwritten workout logs, Apple Notes, or other workout records. Extract all workout data you can find. + +IMPORTANT RULES: +- If you can identify a date for the workout, include it as an ISO date string (YYYY-MM-DD) +- If no date is visible, set date to null +- Extract exercise names as closely as written +- For each exercise, extract all sets with whatever data is visible (reps, weight, duration, etc.) +- If you're unsure about an exercise name or value, set "uncertain": true and explain in "uncertainReason" +- Weight units: assume lbs unless kg or kilograms is explicitly written +- For cardio exercises (running, biking, rowing, assault bike, jump rope, etc.), look for duration, distance, and calories +- Be conservative — only include data you can actually read + +Return ONLY valid JSON with this exact structure (no markdown, no code fences): +{ + "workouts": [ + { + "date": "2025-01-15" or null, + "name": "Upper Body" or null, + "notes": "any overall notes" or null, + "exercises": [ + { + "name": "Bench Press", + "type": "barbell" | "dumbbell" | "machine" | "cable" | "bodyweight" | "cardio" | "kettlebell" | "other", + "sets": [ + { + "reps": 8, + "weight": 225, + "weightUnit": "lbs", + "durationSeconds": null, + "distance": null, + "distanceUnit": null, + "calories": null, + "rpe": null, + "notes": null + } + ], + "notes": null, + "uncertain": false, + "uncertainReason": null + } + ] + } + ], + "confidence": "high" | "medium" | "low", + "warnings": ["list any legibility issues or assumptions made"] +}`; + +export async function POST(request: Request) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Get user's Claude API key from preferences + const preferences = await prisma.userPreferences.findUnique({ + where: { userId: user.id }, + }); + + if (!preferences?.enableClaudeAI || !preferences?.claudeApiKey) { + return NextResponse.json( + { + error: "Claude AI is not configured. Please add your API key in Settings.", + code: "NO_API_KEY", + }, + { status: 400 } + ); + } + + const body = await request.json(); + const validated = importSchema.parse(body); + + // Build Claude API request with vision + const content: any[] = [ + { + type: "text", + text: "Please analyze the following workout log image(s) and extract all workout data. Return ONLY valid JSON.", + }, + ]; + + // Add each image + for (const imageData of validated.images) { + // imageData could be a data URL or raw base64 + let base64 = imageData; + let mediaType = "image/jpeg"; + + if (imageData.startsWith("data:")) { + const match = imageData.match(/^data:(image\/\w+);base64,(.+)$/); + if (match) { + mediaType = match[1]; + base64 = match[2]; + } + } + + content.push({ + type: "image", + source: { + type: "base64", + media_type: mediaType, + data: base64, + }, + }); + } + + // Call Claude API + const claudeResponse = await fetch(CLAUDE_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": preferences.claudeApiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-20250514", + max_tokens: 4096, + system: SYSTEM_PROMPT, + messages: [ + { + role: "user", + content, + }, + ], + }), + }); + + if (!claudeResponse.ok) { + const errorBody = await claudeResponse.text(); + console.error("Claude API error:", claudeResponse.status, errorBody); + + if (claudeResponse.status === 401) { + return NextResponse.json( + { error: "Invalid Claude API key. Please check your key in Settings.", code: "INVALID_KEY" }, + { status: 400 } + ); + } + if (claudeResponse.status === 429) { + return NextResponse.json( + { error: "Claude API rate limit reached. Please try again in a moment.", code: "RATE_LIMITED" }, + { status: 429 } + ); + } + + return NextResponse.json( + { error: "Failed to analyze images. Please try again.", code: "API_ERROR" }, + { status: 502 } + ); + } + + const claudeData = await claudeResponse.json(); + + // Extract text content from Claude's response + const textContent = claudeData.content?.find((c: any) => c.type === "text"); + if (!textContent?.text) { + return NextResponse.json( + { error: "No response from Claude. Please try again.", code: "EMPTY_RESPONSE" }, + { status: 502 } + ); + } + + // Parse the JSON response + let parsed; + try { + // Try to extract JSON from the response (Claude might wrap it in code fences) + let jsonText = textContent.text.trim(); + const jsonMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + jsonText = jsonMatch[1].trim(); + } + parsed = JSON.parse(jsonText); + } catch { + console.error("Failed to parse Claude response:", textContent.text); + return NextResponse.json( + { + error: "Could not parse the workout data. The image may be too unclear.", + code: "PARSE_ERROR", + raw: textContent.text.substring(0, 500), + }, + { status: 422 } + ); + } + + // Validate basic structure + if (!parsed.workouts || !Array.isArray(parsed.workouts)) { + return NextResponse.json( + { error: "Invalid response structure from Claude.", code: "INVALID_STRUCTURE" }, + { status: 422 } + ); + } + + return NextResponse.json(parsed); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.errors }, + { status: 400 } + ); + } + + console.error("POST /api/workouts/import error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/workouts/import/save/route.ts b/workout-planner/app/api/workouts/import/save/route.ts new file mode 100644 index 0000000..da90a2d --- /dev/null +++ b/workout-planner/app/api/workouts/import/save/route.ts @@ -0,0 +1,150 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +const setSchema = z.object({ + reps: z.number().int().positive().optional(), + weight: z.number().positive().optional(), + weightUnit: z.string().optional(), + durationSeconds: z.number().int().positive().optional(), + distance: z.number().positive().optional(), + distanceUnit: z.string().optional(), + calories: z.number().int().positive().optional(), + rpe: z.number().int().min(1).max(10).optional(), + notes: z.string().optional(), +}); + +const exerciseSchema = z.object({ + name: z.string().min(1), + type: z.string().optional(), + existingExerciseId: z.string().optional(), + sets: z.array(setSchema), +}); + +const workoutSchema = z.object({ + date: z.string(), + name: z.string().optional(), + notes: z.string().optional(), + exercises: z.array(exerciseSchema), +}); + +const saveImportSchema = z.object({ + workouts: z.array(workoutSchema).min(1), +}); + +export async function POST(request: Request) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const validated = saveImportSchema.parse(body); + + // Load all user exercises for matching + const existingExercises = await prisma.exercise.findMany({ + where: { userId: user.id }, + }); + + // Build a case-insensitive lookup map + const exerciseMap = new Map(); + for (const ex of existingExercises) { + exerciseMap.set(ex.name.toLowerCase(), ex); + } + + const createdWorkoutIds: string[] = []; + + for (const workoutData of validated.workouts) { + // Resolve exercise IDs (match existing or create new) + const resolvedExercises: Array<{ + exerciseId: string; + sets: z.infer[]; + }> = []; + + for (const ex of workoutData.exercises) { + let exerciseId: string; + + if (ex.existingExerciseId) { + // User explicitly matched this to an existing exercise + exerciseId = ex.existingExerciseId; + } else { + // Try case-insensitive match + const matched = exerciseMap.get(ex.name.toLowerCase()); + if (matched) { + exerciseId = matched.id; + } else { + // Create new exercise + const newExercise = await prisma.exercise.create({ + data: { + userId: user.id, + name: ex.name, + type: ex.type || "other", + muscleGroups: JSON.stringify([]), + isCustom: true, + } as any, + }); + exerciseId = newExercise.id; + // Add to map so subsequent references can find it + exerciseMap.set(ex.name.toLowerCase(), newExercise); + } + } + + resolvedExercises.push({ exerciseId, sets: ex.sets }); + } + + // Create the workout with all sets + const setLogsData: any[] = []; + for (const resolved of resolvedExercises) { + resolved.sets.forEach((set, index) => { + setLogsData.push({ + exerciseId: resolved.exerciseId, + setNumber: index + 1, + reps: set.reps || null, + weight: set.weight || null, + weightUnit: set.weightUnit || "lbs", + rpe: set.rpe || null, + durationSeconds: set.durationSeconds || null, + distance: set.distance || null, + distanceUnit: set.distanceUnit || null, + calories: set.calories || null, + notes: set.notes || null, + }); + }); + } + + const workout = await prisma.workout.create({ + data: { + userId: user.id, + date: new Date(workoutData.date), + name: workoutData.name || null, + notes: workoutData.notes || null, + setLogs: { + create: setLogsData, + }, + } as any, + }); + + createdWorkoutIds.push(workout.id); + } + + return NextResponse.json({ + created: createdWorkoutIds, + count: createdWorkoutIds.length, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.errors }, + { status: 400 } + ); + } + + console.error("POST /api/workouts/import/save error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/api/workouts/route.ts b/workout-planner/app/api/workouts/route.ts new file mode 100644 index 0000000..0e23317 --- /dev/null +++ b/workout-planner/app/api/workouts/route.ts @@ -0,0 +1,192 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma, setCaloriesBurned, getCaloriesBurnedBulk } from "@/lib/prisma"; + +// Schema now supports creating empty workouts (just date) or with sets +const createWorkoutSchema = z.object({ + name: z.string().optional(), + notes: z.string().optional(), + durationMinutes: z.number().int().positive().optional(), + difficulty: z.number().int().min(1).max(10).optional(), + caloriesBurned: z.number().int().positive().optional(), + date: z.string().optional(), // ISO date string or date-only string + sets: z + .array( + z.object({ + exerciseId: z.string(), + setNumber: z.number().int().positive(), + reps: z.number().int().positive().optional(), + weight: z.number().positive().optional(), + weightUnit: z.string().default("lbs"), + rpe: z.number().int().min(1).max(10).optional(), + durationSeconds: z.number().int().positive().optional(), + distance: z.number().positive().optional(), + distanceUnit: z.string().optional(), + calories: z.number().int().positive().optional(), + notes: z.string().optional(), + }) + ) + .optional() + .default([]), +}); + +// GET: List workouts with search/date filters +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const searchParams = request.nextUrl.searchParams; + const query = searchParams.get("q"); + const dateFrom = searchParams.get("dateFrom"); + const dateTo = searchParams.get("dateTo"); + const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100); + const offset = parseInt(searchParams.get("offset") || "0"); + + const where: any = { + userId: user.id, + }; + + if (query) { + where.name = { + contains: query, + }; + } + + if (dateFrom || dateTo) { + where.date = {}; + if (dateFrom) { + where.date.gte = new Date(dateFrom); + } + if (dateTo) { + const toDate = new Date(dateTo); + toDate.setHours(23, 59, 59, 999); + where.date.lte = toDate; + } + } + + const [workouts, total] = await Promise.all([ + prisma.workout.findMany({ + where, + include: { + setLogs: { + include: { + exercise: true, + }, + orderBy: { + setNumber: "asc", + }, + }, + }, + orderBy: { + date: "desc", + }, + take: limit, + skip: offset, + }), + prisma.workout.count({ where }), + ]); + + // Supplement with caloriesBurned from raw SQL + const ids = workouts.map((w) => w.id); + const caloriesMap = await getCaloriesBurnedBulk(ids); + const enriched = workouts.map((w) => ({ + ...w, + caloriesBurned: caloriesMap[w.id] ?? null, + })); + + return NextResponse.json({ + data: enriched, + meta: { + total, + limit, + offset, + hasMore: offset + limit < total, + }, + }); + } catch (error) { + console.error("Failed to fetch workouts:", error); + return NextResponse.json( + { error: "Failed to fetch workouts" }, + { status: 500 } + ); + } +} + +// POST: Create workout (can be empty or with sets) +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const validated = createWorkoutSchema.parse(body); + + const workoutDate = validated.date ? new Date(validated.date) : new Date(); + + // Extract caloriesBurned — handled via raw SQL after creation + const caloriesValue = validated.caloriesBurned; + + const createData: any = { + userId: user.id, + name: validated.name || null, + notes: validated.notes, + durationMinutes: validated.durationMinutes, + difficulty: validated.difficulty, + // caloriesBurned handled separately via raw SQL + date: workoutDate, + setLogs: + validated.sets.length > 0 + ? { + create: validated.sets.map((set) => ({ + exerciseId: set.exerciseId, + setNumber: set.setNumber, + reps: set.reps, + weight: set.weight, + weightUnit: set.weightUnit, + rpe: set.rpe, + durationSeconds: set.durationSeconds, + distance: set.distance, + distanceUnit: set.distanceUnit, + calories: set.calories, + notes: set.notes, + } as any)), + } + : undefined, + }; + + const includeOpts = { + setLogs: { + include: { exercise: true }, + orderBy: { setNumber: "asc" as const }, + }, + }; + + const workout = await prisma.workout.create({ data: createData, include: includeOpts }); + + // Set caloriesBurned via raw SQL + if (caloriesValue !== undefined) { + await setCaloriesBurned(workout.id, caloriesValue); + } + + return NextResponse.json({ ...workout, caloriesBurned: caloriesValue ?? null }, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.errors }, + { status: 400 } + ); + } + + console.error("Failed to create workout:", error); + return NextResponse.json( + { error: "Failed to create workout" }, + { status: 500 } + ); + } +} diff --git a/workout-planner/app/auth/login/actions.ts b/workout-planner/app/auth/login/actions.ts new file mode 100644 index 0000000..18bcb9b --- /dev/null +++ b/workout-planner/app/auth/login/actions.ts @@ -0,0 +1,48 @@ +'use server'; + +import { redirect } from 'next/navigation'; +import { cookies } from 'next/headers'; +import { verifyPassword, createSession } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export async function loginAction(email: string, password: string) { + try { + // Look up user by email + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return { error: 'Invalid email or password' }; + } + + // Verify the password + const isValid = await verifyPassword(password, user.passwordHash); + + if (!isValid) { + return { error: 'Invalid email or password' }; + } + + // Create a session + const session = await createSession(user.id); + + // Set the session cookie + const cookieStore = await cookies(); + cookieStore.set('sessionToken', session.token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, // 30 days + path: '/', + }); + + return { success: true }; + } catch (error) { + console.error('Login error:', error); + return { error: 'An error occurred during login' }; + } +} + +export async function redirectToDashboard() { + redirect('/main/dashboard'); +} diff --git a/workout-planner/app/auth/login/page.tsx b/workout-planner/app/auth/login/page.tsx new file mode 100644 index 0000000..de690da --- /dev/null +++ b/workout-planner/app/auth/login/page.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { loginAction } from './actions'; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const result = await loginAction(email, password); + + if (result.error) { + setError(result.error); + setLoading(false); + return; + } + + if (result.success) { + router.push('/main/dashboard'); + } + } catch (err) { + setError('An unexpected error occurred'); + setLoading(false); + } + }; + + return ( +
+
+
+
+

+ Workout Planner +

+

+ Track. Lift. Dominate. +

+
+ +
+
+
+ + setEmail(e.target.value)} + required + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all" + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + required + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all" + disabled={loading} + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ +

+ Demo: admin@example.com / password +

+
+
+ ); +} diff --git a/workout-planner/app/globals.css b/workout-planner/app/globals.css new file mode 100644 index 0000000..0e6ea4f --- /dev/null +++ b/workout-planner/app/globals.css @@ -0,0 +1,51 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --sidebar-width: 240px; + --nav-height: 64px; + --bottom-nav-height: 64px; +} + +html { + scroll-behavior: smooth; +} + +body { + @apply bg-black text-white; + background-color: #0a0a0a; +} + +/* Premium heading typography */ +h1, h2, h3 { + font-family: var(--font-display), sans-serif; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +/* Scrollbar styling for dark mode */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #0a0a0a; +} + +::-webkit-scrollbar-thumb { + background: #262626; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #404040; +} + +/* App shell layout utilities */ +@layer components { + .app-content { + @apply w-full md:pl-[var(--sidebar-width)]; + } +} diff --git a/workout-planner/app/layout.tsx b/workout-planner/app/layout.tsx new file mode 100644 index 0000000..4657e40 --- /dev/null +++ b/workout-planner/app/layout.tsx @@ -0,0 +1,58 @@ +import type { Metadata, Viewport } from 'next'; +import { Space_Grotesk, Bebas_Neue } from 'next/font/google'; +import './globals.css'; +import Script from 'next/script'; + +const spaceGrotesk = Space_Grotesk({ + subsets: ['latin'], + variable: '--font-sans', + display: 'swap', +}); + +const bebasNeue = Bebas_Neue({ + weight: '400', + subsets: ['latin'], + variable: '--font-display', + display: 'swap', +}); + +export const viewport: Viewport = { + themeColor: '#0A0A0A', + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + +export const metadata: Metadata = { + title: 'Workout Planner', + description: 'Track. Lift. Dominate.', + manifest: '/manifest.json', + appleWebApp: { + capable: true, + statusBarStyle: 'black-translucent', + title: 'Workout', + }, + icons: { + icon: '/icons/favicon.svg', + apple: '/icons/icon-192x192.png', + }, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + {children} +