diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml new file mode 100644 index 0000000..730c42b --- /dev/null +++ b/.github/workflows/ios-build.yml @@ -0,0 +1,105 @@ +name: Build iOS App + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + build-ios: + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install Meteor + run: | + curl https://install.meteor.com/ | sh + + - name: Install dependencies + run: | + meteor npm install + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install iOS Simulator + run: | + sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + xcrun simctl list devicetypes + + - name: Add iOS platform + run: | + meteor add-platform ios + + - name: Build iOS app + run: | + meteor build ../build --server=https://timeharbor-app.meteorapp.com + + - name: Archive iOS app + run: | + cd ../build/ios/project + xcodebuild archive \ + -workspace TimeHarbor.xcworkspace \ + -scheme TimeHarbor \ + -configuration Release \ + -archivePath ../TimeHarbor.xcarchive \ + CODE_SIGNING_ALLOWED=NO + + - name: Export IPA + run: | + cd ../build/ios + xcodebuild -exportArchive \ + -archivePath TimeHarbor.xcarchive \ + -exportPath . \ + -exportOptionsPlist exportOptions.plist + + - name: Create export options + run: | + cd ../build/ios + cat > exportOptions.plist << EOF + + + + + method + development + teamID + \${APPLE_TEAM_ID} + compileBitcode + + uploadBitcode + + + + EOF + + - name: Upload iOS build artifacts + uses: actions/upload-artifact@v4 + with: + name: ios-build + path: | + ../build/ios/*.ipa + ../build/ios/project/ + retention-days: 30 + + - name: Upload build logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ios-build-logs + path: | + ../build/ios/project/*.log + ~/.meteor/logs/ + retention-days: 7 \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2658d7..4543cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,24 @@ node_modules/ +.meteor/local/ +.meteor/meteorite/ + +# Build artifacts +*.ipa +*.app +*.xcarchive +build/ + +# iOS specific +ios/ +cordova-platforms/ +cordova-plugins/ + +# Development files +.DS_Store +*.log + +# Certificates and keys (never commit these!) +*.p12 +*.pem +*.mobileprovision +*.certSigningRequest diff --git a/.meteor/packages b/.meteor/packages index 044a30e..c029ae3 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -19,9 +19,15 @@ ecmascript@0.16.10 # Enable ECMAScript2015+ syntax in app code typescript@5.6.3 # Enable TypeScript syntax in .ts and .tsx modules shell-server@0.6.1 # Server-side component of the `meteor shell` command - - hot-module-replacement@0.5.4 # Update code in development without reloading the page blaze-hot # Update files using Blaze's API with HMR accounts-base accounts-password + +# Push notifications for mobile +push +raix:push + +# Mobile/Cordova support +mobile-status-bar +launch-screen diff --git a/.meteor/platforms b/.meteor/platforms index efeba1b..cc0f96a 100644 --- a/.meteor/platforms +++ b/.meteor/platforms @@ -1,2 +1,3 @@ server browser +ios diff --git a/client/main.css b/client/main.css index 063d83b..094f389 100644 --- a/client/main.css +++ b/client/main.css @@ -2,4 +2,122 @@ @import "tailwindcss"; @plugin "daisyui"; -/* You can add custom styles below if needed */ +/* Mobile-specific enhancements */ +@media (max-width: 768px) { + .navbar { + padding: 0.5rem 1rem; + } + + .navbar .flex-1 h2 { + font-size: 1.5rem; + } + + .navbar nav { + gap: 0.5rem; + } + + .navbar nav .btn { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + } + + .container { + padding-left: 1rem; + padding-right: 1rem; + } + + .card { + margin-bottom: 1rem; + } + + /* Improve touch targets for mobile */ + .btn { + min-height: 44px; + min-width: 44px; + } + + .input { + min-height: 44px; + } + + .checkbox { + min-height: 20px; + min-width: 20px; + } + + /* Better spacing for mobile forms */ + .form-control { + margin-bottom: 1rem; + } + + /* Improve readability */ + .text-xl { + font-size: 1.25rem; + } + + .text-2xl { + font-size: 1.5rem; + } +} + +/* iOS safe area support */ +@supports (padding-top: env(safe-area-inset-top)) { + .navbar { + padding-top: calc(0.5rem + env(safe-area-inset-top)); + } + + body { + padding-bottom: env(safe-area-inset-bottom); + } + + /* Bottom navigation safe area */ + nav[class*="bottom"] { + padding-bottom: calc(0.5rem + env(safe-area-inset-bottom)); + } +} + +/* Mobile bottom navigation styling */ +@media (max-width: 768px) { + .fixed.bottom-0 nav { + border-top: 1px solid rgba(255, 255, 255, 0.1); + } + + .fixed.bottom-0 nav .btn { + border-radius: 0; + font-size: 0.75rem; + } +} + +/* Mobile menu overlay */ +#mobileMenuOverlay { + z-index: 9999; +} + +#mobileMenuOverlay > div { + transform: translateX(100%); + transition: transform 0.3s ease-in-out; +} + +#mobileMenuOverlay:not(.hidden) > div { + transform: translateX(0); +} + +/* Custom styles for notification preferences */ +.notification-settings { + max-width: 100%; + overflow-x: hidden; +} + +.notification-settings .checkbox { + margin-right: 0.75rem; +} + +.notification-settings .label { + align-items: flex-start; + padding: 0.5rem 0; +} + +/* Ensure proper touch scrolling on iOS */ +.overflow-auto { + -webkit-overflow-scrolling: touch; +} diff --git a/client/main.html b/client/main.html index 9bf9543..08a1f3e 100644 --- a/client/main.html +++ b/client/main.html @@ -10,18 +10,71 @@ @@ -221,3 +274,73 @@

Create an Account

+ + diff --git a/client/main.js b/client/main.js index b6a2683..ae2b9b4 100644 --- a/client/main.js +++ b/client/main.js @@ -1,6 +1,6 @@ import { Template } from 'meteor/templating'; import { ReactiveVar } from 'meteor/reactive-var'; -import { Teams, Tickets, ClockEvents } from '../collections.js'; +import { Teams, Tickets, ClockEvents, NotificationPreferences } from '../collections.js'; import './main.html'; @@ -30,6 +30,22 @@ Template.mainLayout.helpers({ }, }); +Template.mainLayout.events({ + 'click #mobileMenuToggle'(e) { + e.preventDefault(); + document.getElementById('mobileMenuOverlay').classList.remove('hidden'); + }, + 'click #closeMobileMenu'(e) { + e.preventDefault(); + document.getElementById('mobileMenuOverlay').classList.add('hidden'); + }, + 'click #mobileMenuOverlay'(e) { + if (e.target.id === 'mobileMenuOverlay') { + document.getElementById('mobileMenuOverlay').classList.add('hidden'); + } + } +}); + Template.mainLayout.events({ 'click nav a'(event) { event.preventDefault(); @@ -579,3 +595,112 @@ Template.home.helpers({ return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; }, }); + +// Notification Settings Template +Template.notificationSettings.onCreated(function () { + this.subscribe('notificationPreferences'); + this.subscribe('userTeams'); +}); + +Template.notificationSettings.helpers({ + notificationsEnabled() { + const prefs = NotificationPreferences.findOne({ userId: Meteor.userId() }); + return prefs ? prefs.enabled : false; + }, + userTeams() { + return Teams.find({ members: Meteor.userId() }); + }, + isProjectSelected(teamId) { + const prefs = NotificationPreferences.findOne({ userId: Meteor.userId() }); + return prefs && prefs.projectNotifications && prefs.projectNotifications.includes(teamId); + }, + isEventTypeSelected(eventType) { + const prefs = NotificationPreferences.findOne({ userId: Meteor.userId() }); + return prefs && prefs.eventTypes && prefs.eventTypes.includes(eventType); + } +}); + +Template.notificationSettings.events({ + 'change #enableNotifications'(e, t) { + const enabled = e.target.checked; + const prefs = NotificationPreferences.findOne({ userId: Meteor.userId() }) || { + projectNotifications: [], + eventTypes: [], + enabled: false + }; + + prefs.enabled = enabled; + Meteor.call('updateNotificationPreferences', prefs); + }, + + 'click #saveNotificationSettings'(e, t) { + e.preventDefault(); + + // Get selected projects + const selectedProjects = []; + t.$('input[type="checkbox"][value]').each(function() { + if (this.checked && this.value.length > 5) { // Team IDs are longer + selectedProjects.push(this.value); + } + }); + + // Get selected event types + const selectedEventTypes = []; + const eventTypeInputs = ['time_logging', 'project_updates', 'new_tickets']; + eventTypeInputs.forEach(eventType => { + if (t.$(`input[value="${eventType}"]`).is(':checked')) { + selectedEventTypes.push(eventType); + } + }); + + const preferences = { + projectNotifications: selectedProjects, + eventTypes: selectedEventTypes, + enabled: t.$('#enableNotifications').is(':checked') + }; + + Meteor.call('updateNotificationPreferences', preferences, (error) => { + if (error) { + alert('Error saving preferences: ' + error.reason); + } else { + alert('Notification preferences saved successfully!'); + } + }); + } +}); + +// Simple router for handling different screens +Template.body.helpers({ + currentScreen() { + return currentScreen.get(); + } +}); + +// Handle navigation +Template.body.events({ + 'click a[href]'(e) { + e.preventDefault(); + const href = e.currentTarget.getAttribute('href'); + + // Close mobile menu if open + const mobileMenu = document.getElementById('mobileMenuOverlay'); + if (mobileMenu && !mobileMenu.classList.contains('hidden')) { + mobileMenu.classList.add('hidden'); + } + + switch (href) { + case '/': + currentTemplate.set('home'); + break; + case '/teams': + currentTemplate.set('teams'); + break; + case '/tickets': + currentTemplate.set('tickets'); + break; + case '/notifications': + currentTemplate.set('notificationSettings'); + break; + } + } +}); diff --git a/collections.js b/collections.js index 853e1f9..8be0fce 100644 --- a/collections.js +++ b/collections.js @@ -3,5 +3,5 @@ import { Mongo } from 'meteor/mongo'; export const Tickets = new Mongo.Collection('tickets'); export const Teams = new Mongo.Collection('teams'); export const Sessions = new Mongo.Collection('sessions'); - export const ClockEvents = new Mongo.Collection('clockevents'); +export const NotificationPreferences = new Mongo.Collection('notificationpreferences'); diff --git a/ios-implementation.md b/ios-implementation.md new file mode 100644 index 0000000..cb9efc6 --- /dev/null +++ b/ios-implementation.md @@ -0,0 +1,134 @@ +# iOS App Implementation + +This document describes the iOS app implementation for TimeHarbor. + +## Features Implemented + +### 1. iOS Platform Support +- Added `ios` platform to `.meteor/platforms` +- Configured mobile-config.js with app metadata, icons, and splash screens +- Added Cordova mobile packages for iOS support + +### 2. Push Notifications +- Integrated `push` and `raix:push` packages for push notification support +- Configured APNs (Apple Push Notification service) settings +- Implemented server-side notification sending infrastructure +- Added notification preferences collection and management + +### 3. Time Logging with Notifications +- Enhanced existing time tracking methods to send notifications +- Notifications are sent when users start/stop time logging on activities +- Team members receive notifications for activity updates in projects they follow + +### 4. Notification Preferences UI +- Created comprehensive notification settings page (`/notifications`) +- Users can enable/disable notifications +- Project-specific notification subscriptions +- Event type filtering (time logging, project updates, new activities) +- Mobile-responsive design with touch-friendly controls + +### 5. Mobile UI Enhancements +- Added mobile-specific CSS optimizations +- Improved touch targets for mobile devices +- Responsive design for various screen sizes +- iOS safe area support for modern devices + +### 6. GitHub Actions CI/CD +- Created automated iOS build workflow +- Generates downloadable IPA files for testing +- Supports code signing configuration +- Includes build artifact uploads + +## Technical Implementation + +### Collections +- **NotificationPreferences**: Stores user notification settings + - `userId`: User identifier + - `projectNotifications`: Array of project IDs to follow + - `eventTypes`: Array of notification types to receive + - `enabled`: Global notification toggle + +### Server Methods +- `updateNotificationPreferences(preferences)`: Update user notification settings +- `subscribeToProjectNotifications(teamId, eventTypes)`: Subscribe to project notifications +- `sendNotification(userId, title, message, data)`: Send push notification to user + +### Client Components +- **notificationSettings**: Full-featured settings management interface +- Navigation integration with main app header +- Reactive UI updates based on preference changes + +### Mobile Configuration +- App ID: `com.mieweb.timeharbor` +- Target iOS version: 12.0+ +- Universal device support (iPhone + iPad) +- Configured for development and production builds + +## Setup Instructions + +### Prerequisites +1. Xcode installed (for iOS builds) +2. Apple Developer account (for production) +3. APNs certificates configured + +### Development Setup +1. Add iOS platform: `meteor add-platform ios` +2. Install dependencies: `meteor npm install` +3. Run with iOS: `meteor run ios` + +### Production Build +1. Configure APNs certificates in environment variables: + - `APN_PASSPHRASE`: Certificate passphrase + - `APN_KEY`: Path to APNs key file + - `APN_CERT`: Path to APNs certificate file +2. Build: `meteor build --server=https://your-server.com` +3. Use GitHub Actions workflow for automated builds + +## Testing + +### Manual Testing +1. Create user account and join/create projects +2. Navigate to `/notifications` to configure preferences +3. Enable notifications and select projects/event types +4. Test time logging to trigger notifications +5. Verify notifications appear on subscribed devices + +### Automated Testing +- Added test coverage for NotificationPreferences collection +- Tests for CRUD operations and data integrity +- Run tests: `meteor test --driver-package meteortesting:mocha` + +## Extensibility + +The notification system is designed for easy expansion: + +### Adding New Event Types +1. Add new event type to notification preferences UI +2. Implement notification sending in relevant server methods +3. Update event type validation and handling + +### Supporting More Platforms +- Android support can be added by including `android` platform +- Cross-platform notification handling already implemented +- UI components are responsive and touch-friendly + +### Advanced Notification Features +- Rich notifications with actions +- Notification scheduling +- User-to-user direct notifications +- Integration with external notification services + +## Known Limitations + +1. **Icons and Splash Screens**: Placeholder structure created, actual assets needed +2. **APNs Certificates**: Must be configured for production use +3. **Push Service**: Currently uses raix:push, may need updates for newer iOS versions +4. **Testing**: Physical device testing required for full push notification validation + +## Future Enhancements + +1. Rich push notifications with images and actions +2. Background notification processing +3. Notification analytics and delivery tracking +4. Advanced notification scheduling +5. Integration with iOS Focus modes and notification categories \ No newline at end of file diff --git a/ios-testing-guide.md b/ios-testing-guide.md new file mode 100644 index 0000000..d2745bb --- /dev/null +++ b/ios-testing-guide.md @@ -0,0 +1,180 @@ +# iOS Device Testing Guide + +This guide provides instructions for testing the TimeHarbor iOS app on physical devices. + +## Prerequisites + +### Development Environment +- macOS with Xcode installed +- Meteor CLI installed +- iOS device (iPhone/iPad) running iOS 12.0 or later +- Apple Developer account (for device testing) + +### Certificate Setup +1. Generate iOS development certificate in Apple Developer Console +2. Create App ID: `com.mieweb.timeharbor` +3. Register test devices by UDID +4. Create development provisioning profile +5. Download and install certificates in Keychain + +## Testing Steps + +### 1. Prepare the App +```bash +# Navigate to project directory +cd /path/to/timeharbor + +# Install dependencies +meteor npm install + +# Add iOS platform (if not already added) +meteor add-platform ios + +# Build for device +meteor run ios-device --mobile-server=https://your-server.com +``` + +### 2. Device Installation +```bash +# Alternative: Build archive for manual installation +meteor build ../build --server=https://your-server.com + +# Install via Xcode +open ../build/ios/project/TimeHarbor.xcworkspace +# Use Xcode to build and install on connected device +``` + +### 3. Push Notification Testing + +#### Setup APNs +1. Configure push notification certificates in Apple Developer Console +2. Export certificates as .p12 files +3. Convert to .pem format: + ```bash + openssl pkcs12 -in cert.p12 -out apns-cert.pem -nodes -clcerts + openssl pkcs12 -in key.p12 -out apns-key.pem -nodes -nocerts + ``` + +#### Update Server Configuration +```javascript +// In server/main.js, update push configuration +Push.Configure({ + apn: { + passphrase: 'your-certificate-passphrase', + key: '/path/to/apns-key.pem', + cert: '/path/to/apns-cert.pem', + production: false // Set to true for production + } +}); +``` + +### 4. Test Scenarios + +#### Basic Functionality +- [ ] App launches successfully +- [ ] User registration and login work +- [ ] Navigation between screens works +- [ ] Touch targets are appropriately sized +- [ ] App responds correctly to device rotation + +#### Time Logging +- [ ] Create new project/team +- [ ] Create new activity/ticket +- [ ] Start/stop time logging +- [ ] View time tracking history +- [ ] Time updates correctly in real-time + +#### Push Notifications +- [ ] Navigate to notification settings +- [ ] Enable push notifications (system permission prompt) +- [ ] Select projects to follow +- [ ] Choose notification event types +- [ ] Save preferences successfully + +#### Notification Delivery +- [ ] Start time logging on one device +- [ ] Verify notification received on other team member devices +- [ ] Check notification content and formatting +- [ ] Test notification actions (if implemented) +- [ ] Verify notifications don't appear for the action performer + +#### Mobile UI/UX +- [ ] Bottom navigation works correctly +- [ ] Mobile menu slides properly +- [ ] Touch scrolling is smooth +- [ ] Forms are easy to use on mobile +- [ ] Text is readable at various zoom levels + +### 5. Performance Testing +- [ ] App launch time < 3 seconds +- [ ] Navigation transitions are smooth +- [ ] No memory leaks during extended use +- [ ] Battery usage is reasonable +- [ ] Network requests handle poor connectivity + +### 6. Device-Specific Testing + +#### iPhone Testing +- [ ] iPhone SE (small screen) +- [ ] iPhone 8 (standard screen) +- [ ] iPhone 12+ (Face ID, notch) +- [ ] iPhone 14+ (Dynamic Island) + +#### iPad Testing +- [ ] iPad (standard size) +- [ ] iPad Pro (large screen) +- [ ] Split-screen multitasking +- [ ] Keyboard support + +#### iOS Version Testing +- [ ] iOS 12.x (minimum supported) +- [ ] iOS 15.x (common version) +- [ ] iOS 16.x (current major) +- [ ] iOS 17.x (latest) + +### 7. Troubleshooting + +#### Common Issues +- **App won't install**: Check provisioning profile and device registration +- **Push notifications not working**: Verify APNs certificates and configuration +- **App crashes on launch**: Check console logs for JavaScript errors +- **Time logging not syncing**: Verify server connection and Meteor DDP + +#### Debug Tools +- Safari Web Inspector for debugging web views +- Xcode console for native crash logs +- Meteor logs for server-side issues +- Network tab for API call debugging + +### 8. Production Deployment + +#### App Store Preparation +1. Update mobile-config.js with production settings +2. Create production APNs certificates +3. Build with production server URL +4. Test with production push service +5. Create App Store screenshots and metadata +6. Submit for App Store review + +#### TestFlight Distribution +1. Archive app with distribution certificate +2. Upload to App Store Connect +3. Add external testers +4. Distribute beta builds for wider testing + +## Reporting Issues + +When reporting bugs or issues: +1. Include device model and iOS version +2. Provide step-by-step reproduction steps +3. Include screenshots or screen recordings +4. Check browser console for JavaScript errors +5. Include relevant server logs if available + +## Next Steps + +After successful device testing: +- [ ] Update documentation with any issues found +- [ ] Submit bug fixes and improvements +- [ ] Prepare for App Store submission +- [ ] Plan Android version development \ No newline at end of file diff --git a/mobile-config.js b/mobile-config.js new file mode 100644 index 0000000..b0c6676 --- /dev/null +++ b/mobile-config.js @@ -0,0 +1,54 @@ +App.info({ + id: 'com.mieweb.timeharbor', + name: 'TimeHarbor', + description: 'Personal time tracking assistant', + author: 'MIE Web', + email: 'support@mieweb.com', + website: 'https://github.com/mieweb/timeharbor', + version: '1.0.0' +}); + +App.icons({ + 'iphone_2x': 'public/icons/icon-60@2x.png', + 'iphone_3x': 'public/icons/icon-60@3x.png', + 'ipad': 'public/icons/icon-76.png', + 'ipad_2x': 'public/icons/icon-76@2x.png', + 'ipad_pro': 'public/icons/icon-83.5@2x.png', + 'ios_settings': 'public/icons/icon-29.png', + 'ios_settings_2x': 'public/icons/icon-29@2x.png', + 'ios_settings_3x': 'public/icons/icon-29@3x.png', + 'ios_spotlight': 'public/icons/icon-40.png', + 'ios_spotlight_2x': 'public/icons/icon-40@2x.png', + 'ios_spotlight_3x': 'public/icons/icon-40@3x.png', + 'ios_notification': 'public/icons/icon-20.png', + 'ios_notification_2x': 'public/icons/icon-20@2x.png', + 'ios_notification_3x': 'public/icons/icon-20@3x.png' +}); + +App.launchScreens({ + 'iphone5': 'public/splash/splash-568h@2x.png', + 'iphone6': 'public/splash/splash-667h@2x.png', + 'iphone6p_portrait': 'public/splash/splash-736h@3x.png', + 'iphone6p_landscape': 'public/splash/splash-736w@3x.png', + 'iphonex_portrait': 'public/splash/splash-1125h@3x.png', + 'iphonex_landscape': 'public/splash/splash-1125w@3x.png', + 'ipad_portrait': 'public/splash/splash-768h.png', + 'ipad_portrait_2x': 'public/splash/splash-768h@2x.png', + 'ipad_landscape': 'public/splash/splash-1024w.png', + 'ipad_landscape_2x': 'public/splash/splash-1024w@2x.png' +}); + +App.setPreference('BackgroundMode', 'audio'); +App.setPreference('Orientation', 'portrait'); +App.setPreference('AutoHideSplashScreen', false); +App.setPreference('SplashScreenDelay', 3000); +App.setPreference('ShowSplashScreenSpinner', false); + +// iOS specific settings +App.setPreference('target-device', 'universal'); +App.setPreference('deployment-target', '12.0'); + +// Push notification configuration +App.setPreference('ios-configuration-type', 'debug'); + +App.accessRule('*'); \ No newline at end of file diff --git a/public/README.md b/public/README.md new file mode 100644 index 0000000..2df41b9 --- /dev/null +++ b/public/README.md @@ -0,0 +1,33 @@ +# iOS Icons and Splash Screens + +This directory contains the required icons and splash screens for the iOS app. + +## Required Icon Sizes: +- icon-20.png (20x20) - Notification +- icon-20@2x.png (40x40) - Notification @2x +- icon-20@3x.png (60x60) - Notification @3x +- icon-29.png (29x29) - Settings +- icon-29@2x.png (58x58) - Settings @2x +- icon-29@3x.png (87x87) - Settings @3x +- icon-40.png (40x40) - Spotlight +- icon-40@2x.png (80x80) - Spotlight @2x +- icon-40@3x.png (120x120) - Spotlight @3x +- icon-60@2x.png (120x120) - iPhone app @2x +- icon-60@3x.png (180x180) - iPhone app @3x +- icon-76.png (76x76) - iPad app +- icon-76@2x.png (152x152) - iPad app @2x +- icon-83.5@2x.png (167x167) - iPad Pro app + +## Required Splash Screen Sizes: +- splash-568h@2x.png (640x1136) - iPhone 5 +- splash-667h@2x.png (750x1334) - iPhone 6/7/8 +- splash-736h@3x.png (1242x2208) - iPhone 6/7/8 Plus Portrait +- splash-736w@3x.png (2208x1242) - iPhone 6/7/8 Plus Landscape +- splash-1125h@3x.png (1125x2436) - iPhone X Portrait +- splash-1125w@3x.png (2436x1125) - iPhone X Landscape +- splash-768h.png (768x1024) - iPad Portrait +- splash-768h@2x.png (1536x2048) - iPad Portrait @2x +- splash-1024w.png (1024x768) - iPad Landscape +- splash-1024w@2x.png (2048x1536) - iPad Landscape @2x + +Note: Placeholder images should be created for development. Final images should be provided by design team. \ No newline at end of file diff --git a/server/main.js b/server/main.js index 057b7ed..e6f4abf 100644 --- a/server/main.js +++ b/server/main.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { check } from 'meteor/check'; -import { Tickets, Teams, Sessions, ClockEvents } from '../collections.js'; +import { Tickets, Teams, Sessions, ClockEvents, NotificationPreferences } from '../collections.js'; function generateTeamCode() { // Simple random code, can be improved for production @@ -77,6 +77,41 @@ Meteor.publish('usersByIds', async function (userIds) { return Meteor.users.find({ _id: { $in: filteredUserIds } }, { fields: { username: 1 } }); }); +// Helper function to send time logging notifications +async function sendTimeLoggingNotification(teamId, userId, action, ticketTitle) { + const team = await Teams.findOneAsync(teamId); + if (!team) return; + + // Find all team members who want to receive time logging notifications + const teamMembers = team.members || []; + const preferences = await NotificationPreferences.find({ + userId: { $in: teamMembers }, + projectNotifications: teamId, + eventTypes: 'time_logging', + enabled: true + }).fetchAsync(); + + const user = await Meteor.users.findOneAsync(userId); + const username = user?.username || 'Someone'; + + for (const pref of preferences) { + if (pref.userId !== userId) { // Don't notify the person who performed the action + await Meteor.call('sendNotification', + pref.userId, + `Time Logging - ${team.name}`, + `${username} ${action} time on "${ticketTitle}"`, + { + type: 'time_logging', + teamId, + ticketTitle, + action, + userId + } + ); + } + } +} + Meteor.methods({ async joinTeamWithCode(teamCode) { check(teamCode, String); @@ -186,26 +221,35 @@ Meteor.methods({ check(seconds, Number); Tickets.update(ticketId, { $inc: { timeSpent: seconds } }); }, - updateTicketStart(ticketId, now) { + async updateTicketStart(ticketId, now) { check(ticketId, String); check(now, Number); if (!this.userId) throw new Meteor.Error('not-authorized'); + + const ticket = await Tickets.findOneAsync(ticketId); + if (ticket) { + await sendTimeLoggingNotification(ticket.teamId, this.userId, 'started', ticket.title); + } + return Tickets.updateAsync(ticketId, { $set: { startTimestamp: now } }); }, - updateTicketStop(ticketId, now) { + async updateTicketStop(ticketId, now) { check(ticketId, String); check(now, Number); if (!this.userId) throw new Meteor.Error('not-authorized'); - return Tickets.findOneAsync(ticketId).then(ticket => { - if (ticket && ticket.startTimestamp) { - const elapsed = Math.floor((now - ticket.startTimestamp) / 1000); - const prev = ticket.accumulatedTime || 0; - return Tickets.updateAsync(ticketId, { - $set: { accumulatedTime: prev + elapsed }, - $unset: { startTimestamp: '' } - }); - } - }); + + const ticket = await Tickets.findOneAsync(ticketId); + if (ticket && ticket.startTimestamp) { + const elapsed = Math.floor((now - ticket.startTimestamp) / 1000); + const prev = ticket.accumulatedTime || 0; + + await sendTimeLoggingNotification(ticket.teamId, this.userId, 'stopped', ticket.title); + + return Tickets.updateAsync(ticketId, { + $set: { accumulatedTime: prev + elapsed }, + $unset: { startTimestamp: '' } + }); + } }, async clockEventStart(teamId) { check(teamId, String); @@ -327,3 +371,123 @@ Meteor.methods({ } }, }); + +// Push notification configuration +Meteor.startup(() => { + // Configure push notifications for production + if (Meteor.isProduction) { + Push.Configure({ + apn: { + passphrase: process.env.APN_PASSPHRASE, + key: process.env.APN_KEY, + cert: process.env.APN_CERT, + production: true + } + }); + } else { + // Development configuration + Push.Configure({ + apn: { + passphrase: 'password', + key: 'apn-dev-key.pem', + cert: 'apn-dev-cert.pem', + production: false + } + }); + } +}); + +// Publish notification preferences for the current user +Meteor.publish('notificationPreferences', function () { + if (!this.userId) return this.ready(); + return NotificationPreferences.find({ userId: this.userId }); +}); + +// Notification-related methods +Meteor.methods({ + async updateNotificationPreferences(preferences) { + check(preferences, { + projectNotifications: [String], + eventTypes: [String], + enabled: Boolean + }); + + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + const existingPrefs = await NotificationPreferences.findOneAsync({ userId: this.userId }); + + if (existingPrefs) { + await NotificationPreferences.updateAsync( + { userId: this.userId }, + { $set: preferences } + ); + } else { + await NotificationPreferences.insertAsync({ + userId: this.userId, + ...preferences, + createdAt: new Date() + }); + } + }, + + async subscribeToProjectNotifications(teamId, eventTypes) { + check(teamId, String); + check(eventTypes, [String]); + + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + // Verify user is a member of the team + const team = await Teams.findOneAsync({ _id: teamId, members: this.userId }); + if (!team) { + throw new Meteor.Error('not-authorized', 'You are not a member of this team'); + } + + const preferences = await NotificationPreferences.findOneAsync({ userId: this.userId }) || { + projectNotifications: [], + eventTypes: [], + enabled: true + }; + + // Add team to project notifications if not already present + if (!preferences.projectNotifications.includes(teamId)) { + preferences.projectNotifications.push(teamId); + } + + // Add event types + eventTypes.forEach(eventType => { + if (!preferences.eventTypes.includes(eventType)) { + preferences.eventTypes.push(eventType); + } + }); + + await Meteor.call('updateNotificationPreferences', preferences); + }, + + async sendNotification(userId, title, message, data = {}) { + check(userId, String); + check(title, String); + check(message, String); + check(data, Object); + + // Check if user has notifications enabled + const preferences = await NotificationPreferences.findOneAsync({ userId }); + if (!preferences || !preferences.enabled) { + return; + } + + // Send push notification + Push.send({ + from: 'TimeHarbor', + title: title, + text: message, + badge: 1, + sound: 'default', + payload: data, + query: { userId: userId } + }); + } +}); diff --git a/tests/main.js b/tests/main.js index e6f18e3..cff1ac2 100644 --- a/tests/main.js +++ b/tests/main.js @@ -1,4 +1,5 @@ import assert from "assert"; +import { NotificationPreferences } from "../collections.js"; describe("timeharbor", function () { it("package.json has correct name", async function () { @@ -16,5 +17,76 @@ describe("timeharbor", function () { it("server is not client", function () { assert.strictEqual(Meteor.isClient, false); }); + + describe("Notification Preferences", function () { + beforeEach(function () { + // Clean up any existing test data + NotificationPreferences.remove({}); + }); + + it("should create notification preferences", function () { + const testUserId = "testUser123"; + const preferences = { + userId: testUserId, + projectNotifications: ["team1", "team2"], + eventTypes: ["time_logging", "project_updates"], + enabled: true, + createdAt: new Date() + }; + + const prefId = NotificationPreferences.insert(preferences); + assert(prefId); + + const savedPref = NotificationPreferences.findOne(prefId); + assert.strictEqual(savedPref.userId, testUserId); + assert.strictEqual(savedPref.enabled, true); + assert.strictEqual(savedPref.projectNotifications.length, 2); + assert.strictEqual(savedPref.eventTypes.length, 2); + }); + + it("should find preferences by user ID", function () { + const testUserId = "testUser456"; + const preferences = { + userId: testUserId, + projectNotifications: ["team3"], + eventTypes: ["new_tickets"], + enabled: false, + createdAt: new Date() + }; + + NotificationPreferences.insert(preferences); + + const foundPref = NotificationPreferences.findOne({ userId: testUserId }); + assert(foundPref); + assert.strictEqual(foundPref.userId, testUserId); + assert.strictEqual(foundPref.enabled, false); + }); + + it("should update notification preferences", function () { + const testUserId = "testUser789"; + const preferences = { + userId: testUserId, + projectNotifications: [], + eventTypes: [], + enabled: false, + createdAt: new Date() + }; + + const prefId = NotificationPreferences.insert(preferences); + + NotificationPreferences.update(prefId, { + $set: { + enabled: true, + projectNotifications: ["newTeam"], + eventTypes: ["time_logging"] + } + }); + + const updatedPref = NotificationPreferences.findOne(prefId); + assert.strictEqual(updatedPref.enabled, true); + assert.strictEqual(updatedPref.projectNotifications.length, 1); + assert.strictEqual(updatedPref.eventTypes.length, 1); + }); + }); } });