Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Weekly dependency audit

on:
schedule:
- cron: '0 8 * * 1' # Every Monday at 08:00 UTC
workflow_dispatch: # Allow manual trigger

jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 10
name: npm audit
permissions:
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: app/package-lock.json
- name: Install dependencies
working-directory: app
run: npm ci
- name: Audit production dependencies
working-directory: app
# --omit=dev: dev deps are not in the production bundle
# --audit-level=high: only fail on high/critical vulnerabilities
run: npm audit --audit-level=high --omit=dev
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
name: Azure Static Web Apps CI/CD
name: CI

on:
push:
# Direct pushes to dev deploy to the same production slot as master.
# This is intentional — dev acts as a pre-merge integration check.
# If a dedicated staging slot is added later, split the triggers.
branches:
- master
- dev
Expand All @@ -17,19 +20,23 @@ on:
- 'app/**'
- '.github/workflows/**'

# Cancel in-progress runs on the same branch when a new push arrives.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build_and_deploy_job:
validate:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Test, Build and Deploy
timeout-minutes: 15
name: Lint and test
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
submodules: true
lfs: false
- uses: actions/setup-node@v5
with:
node-version: '22'
Expand All @@ -38,34 +45,49 @@ jobs:
- name: Install dependencies
working-directory: app
run: npm ci
- name: Lint and audit
- name: Lint
working-directory: app
run: |
npm run lint &
LINT_PID=$!
npm audit --audit-level=high &
AUDIT_PID=$!
wait $LINT_PID
LINT_EXIT=$?
wait $AUDIT_PID
AUDIT_EXIT=$?
if [ $LINT_EXIT -ne 0 ] || [ $AUDIT_EXIT -ne 0 ]; then exit 1; fi
run: npm run lint
- name: Test with coverage
working-directory: app
run: npm run test:ci

deploy:
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 15
name: Build and deploy
environment: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
submodules: true
- uses: actions/setup-node@v5
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: app/package-lock.json
- name: Install dependencies
working-directory: app
run: npm ci
- name: Restore Vite build cache
uses: actions/cache@v5
with:
path: app/node_modules/.vite
key: vite-${{ runner.os }}-${{ hashFiles('app/src/**', 'app/vite.config.js') }}
# Keyed on deps + vite config only — source changes don't affect
# the pre-bundled dependency cache in node_modules/.vite.
key: vite-${{ runner.os }}-${{ hashFiles('app/package-lock.json', 'app/vite.config.js') }}
restore-keys: vite-${{ runner.os }}-
- name: Build app
working-directory: app
run: npm run build
env:
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
- name: Build And Deploy
- name: Deploy to Azure Static Web Apps
id: builddeploy
uses: Azure/static-web-apps-deploy@v1
with:
Expand All @@ -74,4 +96,6 @@ jobs:
action: "upload"
app_location: "app/dist"
api_location: "app/api"
# "." = root of the pre-built app/dist folder. The frontend is built
# in CI to bypass Oryx stripping VITE_* env vars. See CLAUDE.md pitfall #9.
output_location: "."
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Azure Static Web Apps — Close PR
name: Cleanup staging environment
run-name: "Cleanup staging env — ${{ github.event.pull_request.title }}"

on:
Expand All @@ -11,7 +11,8 @@ on:
jobs:
close_pull_request_job:
runs-on: ubuntu-latest
name: Close Pull Request Job
timeout-minutes: 5
name: Close staging environment
permissions:
contents: read
pull-requests: write
Expand Down
2 changes: 1 addition & 1 deletion app/api/__tests__/claudeUtils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '../claudeUtils.js';

describe('ALLOWED_MODELS', () => {
it('contains exactly the two production model IDs', () => {
it('contains the production model ID', () => {
expect([...ALLOWED_MODELS].sort()).toEqual(['claude-sonnet-4-6']);
});

Expand Down
25 changes: 25 additions & 0 deletions app/api/__tests__/sportySync.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { normalizeName } from '../sportyUtils.js';

describe('normalizeName', () => {
it('strips a straight-quote annotation', () => {
expect(normalizeName('CROSSFIT "SVART TRØYE"')).toBe('CROSSFIT');
});

it('strips a curly-quote annotation', () => {
// U+201C left double quotation mark, U+201D right double quotation mark
expect(normalizeName('YOGA “KVELDSRO”')).toBe('YOGA');
});

it('leaves a plain name unchanged', () => {
expect(normalizeName('PILATES')).toBe('PILATES');
});

it('returns empty string when the entire name is an annotation', () => {
expect(normalizeName('"ANNONSERT"')).toBe('');
});

it('trims whitespace left after stripping', () => {
expect(normalizeName('CROSSFIT "SVART TRØYE" ')).toBe('CROSSFIT');
});
});
6 changes: 1 addition & 5 deletions app/api/sportySync.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { app } from '@azure/functions';
import { normalizeName } from './sportyUtils.js';

// BU 8 is hardcoded for the single-gym MVP (Sporty Thon Senter Ski).
// When multi-gym support lands, replace with a DB lookup via user_gyms.sporty_business_unit_id.
Expand All @@ -17,11 +18,6 @@ function buildSportyUrl(daysAhead = 10) {
return `${SPORTY_BASE_URL}?period_start=${encodeURIComponent(start.toISOString())}&period_end=${encodeURIComponent(end.toISOString())}`;
}

// Strip quoted annotations like "SVART TRØYE" — handles straight and curly double quotes
function normalizeName(name) {
return name.replace(/\s*["“”][^"“”]+["“”]\s*/g, '').trim();
}

function shiftRow(row, shiftMs) {
if (!shiftMs) return row;
return {
Expand Down
4 changes: 4 additions & 0 deletions app/api/sportyUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Strip quoted annotations like “SVART TRØYE” — handles straight and curly double quotes
export function normalizeName(name) {
return name.replace(/\s*["“”][^"“”]+["“”]\s*/g, '').trim();
}
Loading