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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ Please, document here only changes visible to the client app.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.8.0] - 2026-02-11

### [23 Added Strava Activity Signals Extraction Package](https://github.com/mrbalov/pace/issues/23)

### Added
- New `@pace/strava-activity-signals` package for extracting semantic signals from Strava activity data
- Activity validation module to ensure data integrity before processing
- Intensity classification based on activity pace (Easy, Moderate, Hard, Threshold, Max Effort)
- Elevation classification based on total elevation gain (Flat, Rolling, Hilly, Mountainous)
- Time of day signal extraction from activity timestamps (Early Morning, Morning, Midday, Afternoon, Evening, Night)
- Tag extraction and normalization from activity metadata
- Semantic context extraction from activity name and description using NER techniques
- Forbidden content checking to filter inappropriate language
- Pace calculation utility converting speed to seconds per kilometer
- Text sanitization utility for cleaning and normalizing user input
- Comprehensive signal validation with sanitization fallbacks
- Full test coverage for all signal extraction modules (3689 lines of tests and implementation)

## [1.7.0] - 2026-02-10

### [28 Introduced Test-Driven Development (TDD) Enforcement and Enhanced Development Workflow](https://github.com/mrbalov/pace/issues/28)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pace",
"version": "1.7.0",
"version": "1.8.0",
"description": "Generates AI images based on Strava activity data.",
"type": "module",
"private": true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, test, expect } from 'bun:test';

import checkForbiddenContent from './check-forbidden-content';

type Case = [string, string, boolean];

describe('check-forbidden-content', () => {
describe('detects person-related forbidden content', () => {
test.each<Case>([
['detects person keyword', 'A person running', true],
['detects people keyword', 'Many people at the park', true],
['detects individual keyword', 'An individual athlete', true],
['detects human keyword', 'Human performance', true],
['detects man keyword', 'A man running', true],
['detects woman keyword', 'Woman jogging', true],
['detects child keyword', 'Child playing', true],
['detects kid keyword', 'Kid running around', true],
['detects baby keyword', 'Baby in stroller', true],
['detects face keyword', 'Face in the photo', true],
['detects portrait keyword', 'Portrait photography', true],
['detects photo keyword', 'Photo of the run', true],
['detects picture keyword', 'Picture perfect day', true],
['detects image keyword', 'Image of runner', true],
])('%#. %s', (_name, text, expected) => {
const result = checkForbiddenContent(text);

expect(result).toStrictEqual(expected);
});
});

describe('detects political forbidden content', () => {
test.each<Case>([
['detects political keyword', 'Political rally', true],
['detects politics keyword', 'Politics discussion', true],
['detects government keyword', 'Government building', true],
['detects president keyword', 'President election', true],
['detects election keyword', 'Election day run', true],
['detects vote keyword', 'Vote for change', true],
['detects democracy keyword', 'Democracy march', true],
['detects republican keyword', 'Republican event', true],
['detects democrat keyword', 'Democrat gathering', true],
['detects flag keyword', 'Flag ceremony', true],
['detects banner keyword', 'Banner display', true],
['detects symbol keyword', 'Symbol of freedom', true],
['detects emblem keyword', 'Emblem on shirt', true],
['detects crest keyword', 'Family crest', true],
])('%#. %s', (_name, text, expected) => {
const result = checkForbiddenContent(text);

expect(result).toStrictEqual(expected);
});
});

describe('detects violence forbidden content', () => {
test.each<Case>([
['detects violence keyword', 'Violence in the streets', true],
['detects violent keyword', 'Violent storm', true],
['detects fight keyword', 'Fight for victory', true],
['detects war keyword', 'War memorial', true],
['detects battle keyword', 'Battle training', true],
['detects weapon keyword', 'Weapon training', true],
['detects gun keyword', 'Starting gun', true],
['detects knife keyword', 'Knife edge ridge', true],
['detects sword keyword', 'Sword monument', true],
['detects attack keyword', 'Attack the hill', true],
['detects kill keyword', 'Kill the workout', true],
['detects death keyword', 'Death valley run', true],
['detects blood keyword', 'Blood donation', true],
['detects combat keyword', 'Combat training', true],
['detects military keyword', 'Military base', true],
['detects soldier keyword', 'Soldier field', true],
['detects army keyword', 'Army run', true],
['detects navy keyword', 'Navy pier', true],
])('%#. %s', (_name, text, expected) => {
const result = checkForbiddenContent(text);

expect(result).toStrictEqual(expected);
});
});

describe('detects sexual content forbidden content', () => {
test.each<Case>([
['detects sexual keyword', 'Sexual content warning', true],
['detects sex keyword', 'Sex education', true],
['detects nude keyword', 'Nude beach', true],
['detects naked keyword', 'Naked truth', true],
['detects explicit keyword', 'Explicit content', true],
['detects adult keyword', 'Adult supervision', true],
['detects porn keyword', 'Porn website', true],
])('%#. %s', (_name, text, expected) => {
const result = checkForbiddenContent(text);

expect(result).toStrictEqual(expected);
});
});

describe('detects typography forbidden content', () => {
test.each<Case>([
['detects text keyword', 'Text message', true],
['detects word keyword', 'Word of the day', true],
['detects letter keyword', 'Letter of recommendation', true],
['detects alphabet keyword', 'Alphabet song', true],
['detects typography keyword', 'Typography design', true],
['detects caption keyword', 'Caption this photo', true],
['detects label keyword', 'Label the items', true],
['detects title keyword', 'Title of the run', true],
['detects heading keyword', 'Heading north', true],
['detects font keyword', 'Font selection', true],
['detects type keyword', 'Type of workout', true],
['detects write keyword', 'Write a review', true],
['detects print keyword', 'Print the results', true],
['detects display keyword', 'Display on screen', true],
['detects show keyword', 'Show the data', true],
['detects say keyword', 'Say hello', true],
['detects tell keyword', 'Tell a story', true],
['detects read keyword', 'Read the instructions', true],
])('%#. %s', (_name, text, expected) => {
const result = checkForbiddenContent(text);

expect(result).toStrictEqual(expected);
});
});

describe('handles safe content correctly', () => {
test.each<Case>([
['allows safe running text', 'Morning trail run', false],
['allows safe location text', 'Running through the park', false],
['allows safe activity text', 'Easy recovery jog', false],
['allows safe weather text', 'Sunny morning', false],
['allows safe terrain text', 'Mountain trail', false],
['allows safe distance text', '10k run', false],
['allows safe time text', 'Early morning workout', false],
['allows safe pace text', 'Quick tempo run', false],
['allows safe gear text', 'New running shoes', false],
['allows safe feeling text', 'Feeling strong', false],
])('%#. %s', (_name, text, expected) => {
const result = checkForbiddenContent(text);

expect(result).toStrictEqual(expected);
});
});

describe('handles edge cases correctly', () => {
test.each<Case>([
['handles empty string', '', false],
['handles whitespace only', ' ', false],
['handles uppercase forbidden keyword', 'PEOPLE running', true],
['handles mixed case forbidden keyword', 'PeOpLe running', true],
['handles forbidden keyword at start', 'Government building run', true],
['handles forbidden keyword at end', 'Running with people', true],
['handles forbidden keyword in middle', 'Great people filled event', true],
['handles multiple forbidden keywords', 'Government people with weapons', true],
['handles partial word match that should not trigger', 'Manhattan beach run', false],
['handles special characters', '!@#$%^&*()', false],
])('%#. %s', (_name, text, expected) => {
const result = checkForbiddenContent(text);

expect(result).toStrictEqual(expected);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { PATTERNS } from './constants';

/**
* Checks if text contains forbidden content patterns.
*
* Forbidden content includes:
* - Real persons or identifiable individuals
* - Political or ideological symbols
* - Explicit violence or sexual content
* - Military or combat scenes
* - Text/captions/typography instructions
*
* @param {string} text - Text to check for forbidden content.
* @returns {boolean} True if forbidden content detected, false otherwise.
*/
const checkForbiddenContent = (text: string): boolean => {
const lowerText = text.toLowerCase();

return PATTERNS.some((pattern) => pattern.test(lowerText));
};

export default checkForbiddenContent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Patterns for real persons/identifiable individuals.
*/
export const PERSON_PATTERNS = [
/\b(person|people|individual|human|man|woman|child|kid|baby)\b/,
/\b(face|portrait|photo|picture|image|photo)\b/,
];

/**
* Patterns for political/ideological symbols.
*/
export const POLITICAL_PATTERNS = [
/\b(political|politics|government|president|election|vote|democracy|republican|democrat)\b/,
/\b(flag|banner|symbol|emblem|crest)\b/,
];

/**
* Patterns for violence.
*/
export const VIOLENCE_PATTERNS = [
/\b(violence|violent|fight|war|battle|weapon|gun|knife|sword|attack|kill|death|blood)\b/,
/\b(combat|military|soldier|army|navy|air force)\b/,
];

/**
* Patterns for sexual content.
*/
export const SEXUAL_PATTERNS = [/\b(sexual|sex|nude|naked|explicit|adult|porn)\b/];

/**
* Patterns for text/typography instructions.
*/
export const TEXT_PATTERNS = [
/\b(text|word|letter|alphabet|typography|caption|label|title|heading|font|type)\b/,
/\b(write|print|display|show|say|tell|read)\b/,
];

export const PATTERNS = [
...PERSON_PATTERNS,
...POLITICAL_PATTERNS,
...VIOLENCE_PATTERNS,
...SEXUAL_PATTERNS,
...TEXT_PATTERNS,
];
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './check-forbidden-content';
53 changes: 53 additions & 0 deletions packages/strava-activity-signals/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export const INTENSITIES = ['low', 'medium', 'high'] as const;

export const ELEVATIONS = ['flat', 'rolling', 'mountainous'] as const;

export const TIMES_OF_DAY = ['morning', 'day', 'evening', 'night'] as const;

/**
* World record pace is around 2:30 min/km,
* so anything faster than 2:00 min/km is suspicious.
*/
export const MAX_PACE = 120 as const;

/**
* Classification thresholds and constants for Strava activity signals.
* Defines thresholds for classifying activity intensity, elevation, and time of day.
* Used across classification and validation logic to ensure consistency.
*/
export const CLASSIFICATIONS = {
/** Intensity classification thresholds. */
INTENSITY: {
/**
* Low intensity threshold for pace (seconds per km).
* 6:00 min/km.
*/
LOW_PACE_THRESHOLD: 360,

/**
* High intensity threshold for pace (seconds per km).
* 4:00 min/km.
*/
HIGH_PACE_THRESHOLD: 240,
},

/** Elevation classification thresholds (meters). */
ELEVATION: {
/** Flat terrain threshold. */
FLAT_THRESHOLD: 50,
/** Rolling terrain threshold. */
ROLLING_THRESHOLD: 500,
},

/** Time of day classification. */
TIME_OF_DAY: {
/** Morning start hour (0-23). */
MORNING_START: 5,
/** Morning end hour (0-23). */
MORNING_END: 10,
/** Evening start hour (0-23). */
EVENING_START: 17,
/** Night start hour (0-23). */
NIGHT_START: 20,
},
};
Loading