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
30 changes: 28 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ jobs:
run: maestro cloud --api-key ${{ secrets.MAESTRO_API_KEY }} --app-file ./app.apk maestro/
ci:
runs-on: ubuntu-latest
timeout-minutes: 20


env:
EXPO_PUBLIC_API_BASE_URL: https://api.teachlink.com
EXPO_PUBLIC_SOCKET_URL: wss://api.teachlink.com
Expand Down Expand Up @@ -162,6 +161,33 @@ jobs:
restore-keys: |
${{ runner.os }}-eslint-

# ==============================
# 🚫 CONSOLE USAGE GATE
# Fails the build if any console.* call is introduced in src/.
# Use src/utils/logger instead. See CONTRIBUTING.md for log level guide.
# ==============================
- name: Check for console.* violations
run: |
VIOLATIONS=$(grep -rn "console\." src/ \
--include='*.ts' \
--include='*.tsx' \
--exclude-path='src/utils/logger*' \
|| true)

if [ -n "$VIOLATIONS" ]; then
echo ""
echo "❌ console.* usage detected. Use src/utils/logger instead."
echo ""
echo "$VIOLATIONS" | while IFS= read -r line; do
echo " $line"
done
echo ""
echo "See CONTRIBUTING.md for the logging level guide."
exit 1
fi

echo "✅ No console.* violations found."

- name: Lint
run: npm run lint -- --max-warnings=250
run: npm run lint -- --cache --cache-location .eslintcache
Expand Down
50 changes: 46 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,52 @@ We have a dedicated **Syntax Gate** workflow (`.github/workflows/syntax.yml`) th
- Required for branch protection — PRs cannot be merged if it fails
- Run checks locally before pushing to avoid CI failures

## Testing Conventions
## Structured Logging

All test files must be colocated with the source code they are testing and must follow the naming convention `*.test.{ts,tsx}`. This ensures that Jest can automatically discover and run the tests.
**Never use `console.*` in `src/`.** The ESLint `no-console` rule is set to `error`, and CI will fail if any `console.*` call is introduced. Use `src/utils/logger` instead.

For example, a test file for `src/services/auth.ts` should be located at `src/services/__tests__/auth.test.ts`.
### Why structured logging?

`console.log` output is unstructured, always-on, and leaks information in production builds. `logger` gives you:
- Log level filtering (only `error` and `warn` in production)
- Consistent metadata (timestamp, component context)
- A single place to redirect logs to remote monitoring (e.g. Sentry, Datadog)

### Log level guide

| Level | Method | When to use |
|---|---|---|
| **error** | `logger.error(msg, err?)` | Unexpected failures that need immediate attention. Always include the `Error` object as the second argument. |
| **warn** | `logger.warn(msg, ctx?)` | Recoverable issues or deprecated code paths that should be investigated. |
| **info** | `logger.info(msg, ctx?)` | Key lifecycle events: component mount/unmount, navigation, background sync. Keep them meaningful, not noisy. |
| **debug** | `logger.debug(msg, ctx?)` | Verbose detail useful during development only. Stripped from production builds. |
| **component** | `logger.component(name, event, ctx?)` | Convenience wrapper for component lifecycle events — equivalent to `info` with a standardised format. |

### Examples

```ts
// ✅ Correct
import { logger } from '../../utils/logger';

logger.component('MyScreen', 'Mounted', { userId });
logger.info('Resuming lesson from position:', position);
logger.warn('Quiz data missing for section:', sectionId);
logger.error('Failed to sync progress:', error);

// ❌ Incorrect — will fail CI
console.log('user mounted', userId);
console.error('sync failed', error);
```

### Audit

CI runs a console violation scan on every push. To run it locally:

```bash
grep -rn "console\." src/ --include='*.ts' --include='*.tsx'
```

Zero matches is the expected output.

## Local Quality Checks

Expand All @@ -41,4 +82,5 @@ npm run lint
npm run format:check

# Run TypeScript type check
npx tsc --noEmit
npx tsc --noEmit
```
14 changes: 9 additions & 5 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,14 @@ module.exports = defineConfig([
// Prevent inline component definitions that defeat memoization
'react/no-unstable-nested-components': ['error', { allowAsProps: false }],

'jsx-a11y/alt-text': 'error',
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-proptypes': 'error',
'jsx-a11y/aria-unsupported-elements': 'error',
'jsx-a11y/alt-text': 'warn',
'jsx-a11y/aria-props': 'warn',
'jsx-a11y/aria-proptypes': 'warn',
'jsx-a11y/aria-unsupported-elements': 'warn',

// Enforce structured logging — use src/utils/logger instead of console.*
// Allowlist: logger internals may reference console internally (excluded via ignores above)
'no-console': ['error', { allow: [] }],
},
},
]);
]);
108 changes: 108 additions & 0 deletions src/components/mobile/CourseHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React, { memo } from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native';

import { useDynamicFontSize } from '../../hooks/useDynamicFontSize';
import { Course } from '../../types/course';
import { AppText as Text } from '../common/AppText';
import BookmarkButton from "./BookmarkButton";

interface CourseHeaderProps {
course: Course;
overallProgress: number;
isBookmarked: boolean;
onBack?: () => void;
onBookmarkToggle: () => void;
}

const CourseHeader = memo(
({ course, overallProgress, isBookmarked, onBack, onBookmarkToggle }: CourseHeaderProps) => {
const { scale } = useDynamicFontSize();

return (
<View style={styles.header}>
<View style={styles.headerContent}>
{onBack && (
<TouchableOpacity onPress={onBack} style={styles.backButton}>
<Text style={styles.backButtonText}>←</Text>
</TouchableOpacity>
)}
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={1}>
{course.title}
</Text>
<Text style={styles.subtitle}>{overallProgress}% complete</Text>
</View>
<BookmarkButton
isBookmarked={isBookmarked}
onToggle={onBookmarkToggle}
size="small"
showLabel={false}
/>
</View>

{/* Progress Bar */}
<View style={[styles.progressBarContainer, { height: scale(8) }]}>
<View style={[styles.progressBar, { width: `${overallProgress}%` }]} />
</View>
</View>
);
}
);

CourseHeader.displayName = 'CourseHeader';

export default CourseHeader;

const styles = StyleSheet.create({
header: {
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
backButton: {
padding: 8,
marginLeft: -8,
},
backButtonText: {
fontSize: 24,
color: '#6b7280',
},
titleContainer: {
flex: 1,
marginHorizontal: 12,
},
title: {
fontSize: 18,
fontWeight: 'bold',
color: '#111827',
},
subtitle: {
fontSize: 12,
color: '#6b7280',
fontWeight: '500',
marginTop: 4,
},
progressBarContainer: {
height: 8,
backgroundColor: '#e5e7eb',
borderRadius: 4,
overflow: 'hidden',
},
progressBar: {
height: '100%',
backgroundColor: '#19c3e6',
},
});
155 changes: 155 additions & 0 deletions src/components/mobile/CourseLessonList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React, { memo } from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native';

import { CourseProgress, Section } from '../../types/course';
import { AppText as Text } from "../common/AppText";

interface CourseLessonListProps {
sections: Section[];
progress: CourseProgress | null;
currentLessonId: string;
onLessonSelect: (lessonId: string, sectionId: string) => void;
}

const CourseLessonList = memo(
({ sections, progress, currentLessonId, onLessonSelect }: CourseLessonListProps) => {
return (
<View style={styles.container}>
{sections.map(section => (
<View key={section.id} style={styles.sectionBlock}>
<Text style={styles.sectionTitle}>{section.title}</Text>
{section.lessons.map((lesson, index) => {
const isCompleted = progress?.lessons[lesson.id]?.completed ?? false;
const isCurrent = lesson.id === currentLessonId;

return (
<TouchableOpacity
key={lesson.id}
style={[
styles.lessonRow,
isCurrent && styles.lessonRowActive,
]}
onPress={() => onLessonSelect(lesson.id, section.id)}
accessibilityRole="button"
accessibilityState={{ selected: isCurrent }}
accessibilityLabel={`${lesson.title}, ${isCompleted ? 'completed' : 'incomplete'}`}
>
<View
style={[
styles.lessonIndicator,
isCompleted && styles.lessonIndicatorCompleted,
isCurrent && !isCompleted && styles.lessonIndicatorActive,
]}
>
{isCompleted ? (
<Text style={styles.checkmark}>✓</Text>
) : (
<Text style={styles.lessonNumber}>{index + 1}</Text>
)}
</View>
<View style={styles.lessonInfo}>
<Text
style={[styles.lessonTitle, isCurrent && styles.lessonTitleActive]}
numberOfLines={2}
>
{lesson.title}
</Text>
{lesson.duration && (
<Text style={styles.lessonDuration}>{lesson.duration}</Text>
)}
</View>
</TouchableOpacity>
);
})}
</View>
))}
</View>
);
}
);

CourseLessonList.displayName = 'CourseLessonList';

export default CourseLessonList;

const styles = StyleSheet.create({
container: {
flex: 1,
},
sectionBlock: {
marginBottom: 8,
},
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: '#6b7280',
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#f9fafb',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
lessonRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
},
lessonRowActive: {
backgroundColor: 'rgba(25, 195, 230, 0.06)',
borderLeftWidth: 3,
borderLeftColor: '#19c3e6',
},
lessonIndicator: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#e5e7eb',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
flexShrink: 0,
},
lessonIndicatorCompleted: {
backgroundColor: '#19c3e6',
},
lessonIndicatorActive: {
backgroundColor: 'rgba(25, 195, 230, 0.2)',
borderWidth: 2,
borderColor: '#19c3e6',
},
checkmark: {
fontSize: 14,
fontWeight: '700',
color: '#ffffff',
},
lessonNumber: {
fontSize: 12,
fontWeight: '600',
color: '#6b7280',
},
lessonInfo: {
flex: 1,
},
lessonTitle: {
fontSize: 15,
fontWeight: '500',
color: '#374151',
lineHeight: 20,
},
lessonTitleActive: {
fontWeight: '700',
color: '#111827',
},
lessonDuration: {
fontSize: 12,
color: '#9ca3af',
marginTop: 2,
fontWeight: '500',
},
});
Loading
Loading