Skip to content

Commit a3db76d

Browse files
changes
1 parent e6cefd4 commit a3db76d

35 files changed

+1600
-956
lines changed

.env.development

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,12 @@ API_URL=https://dummyjson.com/
33
## TODO: add the variable to your CI and remove it from here, not recommended setting sensitive values on your git repo
44
SECRET_KEY=my-secret-key
55
VAR_NUMBER=10 # this is a number variable
6-
VAR_BOOL=true # this is a boolean variable
6+
VAR_BOOL=true # this is a boolean variable
7+
8+
EXPO_PUBLIC_API_URL=http://localhost:5001/habits-together/us-central1/api
9+
EXPO_PUBLIC_FIREBASE_API_KEY=demo-api-key
10+
EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=habits-together.firebaseapp.com
11+
EXPO_PUBLIC_FIREBASE_PROJECT_ID=habits-together
12+
EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=habits-together.appspot.com
13+
EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789
14+
EXPO_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abcdef

.eslintrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ module.exports = {
2020
ignore: ['/android', '/ios'],
2121
},
2222
],
23-
'max-params': ['error', 3], // Limit the number of parameters in a function to use object instead
23+
'max-params': ['error', 4], // Limit the number of parameters in a function to use object instead
2424
'react/display-name': 'off',
2525
'react/no-inline-styles': 'off',
2626
'react/destructuring-assignment': 'off', // Vscode doesn't support automatically destructuring, it's a pain to add a new variable

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"expo-splash-screen": "0.27.7",
3434
"expo-status-bar": "~1.12.1",
3535
"expo-system-ui": "~3.0.7",
36+
"firebase": "^11.2.0",
3637
"lodash.memoize": "^4.1.2",
3738
"lottie-react-native": "6.7.0",
3839
"lucide-react-native": "^0.441.0",

pnpm-lock.yaml

Lines changed: 703 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/common/firebase.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { initializeApp } from 'firebase/app';
2+
import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore';
3+
import { connectFunctionsEmulator, getFunctions } from 'firebase/functions';
4+
5+
const firebaseConfig = {
6+
apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY,
7+
authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
8+
projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID,
9+
storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
10+
messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
11+
appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
12+
};
13+
14+
// Initialize Firebase
15+
export const app = initializeApp(firebaseConfig);
16+
export const db = getFirestore(app);
17+
18+
// Connect to emulator in development
19+
if (__DEV__) {
20+
connectFirestoreEmulator(db, '127.0.0.1', 8080);
21+
}
22+
23+
// Connect to Functions emulator
24+
const functions = getFunctions();
25+
if (process.env.NODE_ENV === 'development') {
26+
connectFunctionsEmulator(functions, '127.0.0.1', 5001);
27+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import {
2+
addDoc,
3+
collection,
4+
deleteDoc,
5+
doc,
6+
type FieldValue,
7+
getDoc,
8+
serverTimestamp,
9+
setDoc,
10+
updateDoc,
11+
} from 'firebase/firestore';
12+
13+
import { type habitColors } from '@/ui/colors';
14+
15+
import { db } from '../common/firebase';
16+
import { getUserById, type UserIdT } from '../users';
17+
import {
18+
type DbHabitT,
19+
type DbParticipantT,
20+
type HabitCreationT,
21+
type HabitEntryT,
22+
type HabitIdT,
23+
} from './types';
24+
25+
type ColorName = keyof typeof habitColors;
26+
27+
export const createHabit = async (
28+
habitCreationInfo: HabitCreationT,
29+
): Promise<DbHabitT> => {
30+
const myId = '1' as UserIdT; // TODO: Get from auth context
31+
32+
const newHabit = {
33+
title: habitCreationInfo.title,
34+
description: habitCreationInfo.description,
35+
colorName: habitCreationInfo.colorName as ColorName,
36+
icon: habitCreationInfo.icon,
37+
settings: {
38+
allowMultipleCompletions: habitCreationInfo.allowMultipleCompletions,
39+
},
40+
participants: {
41+
[myId]: {
42+
displayName: 'Alex Chen', // TODO: Get from auth context
43+
username: 'alexchen',
44+
lastActivity: serverTimestamp(),
45+
isOwner: true,
46+
} satisfies Omit<DbParticipantT, 'lastActivity'> & {
47+
lastActivity: FieldValue;
48+
},
49+
},
50+
createdAt: serverTimestamp(),
51+
} satisfies Omit<DbHabitT, 'lastActivity' | 'createdAt'> & {
52+
createdAt: FieldValue;
53+
};
54+
55+
const docRef = await addDoc(collection(db, 'habits'), newHabit);
56+
const created = await getDoc(docRef);
57+
if (!created.exists()) throw new Error('Failed to create habit');
58+
return created.data() as DbHabitT;
59+
};
60+
61+
export const deleteHabit = async (habitId: HabitIdT): Promise<void> => {
62+
await deleteDoc(doc(db, 'habits', habitId));
63+
};
64+
65+
export const editHabit = async (
66+
habitId: HabitIdT,
67+
newHabitInfo: HabitCreationT,
68+
): Promise<DbHabitT> => {
69+
const habitRef = doc(db, 'habits', habitId);
70+
const habitDoc = await getDoc(habitRef);
71+
if (!habitDoc.exists()) throw new Error('Habit not found');
72+
73+
const data = habitDoc.data() as DbHabitT;
74+
const updatedHabit = {
75+
...data,
76+
title: newHabitInfo.title,
77+
description: newHabitInfo.description,
78+
colorName: newHabitInfo.colorName as ColorName,
79+
icon: newHabitInfo.icon,
80+
settings: {
81+
...data.settings,
82+
allowMultipleCompletions: newHabitInfo.allowMultipleCompletions,
83+
},
84+
} satisfies DbHabitT;
85+
86+
await updateDoc(habitRef, updatedHabit);
87+
return updatedHabit;
88+
};
89+
90+
export const acceptHabitInvite = async (habitId: HabitIdT): Promise<void> => {
91+
const myId = '1' as UserIdT; // TODO: Get from auth context
92+
const me = await getUserById(myId);
93+
if (!me) throw new Error('User not found');
94+
95+
const habitRef = doc(db, 'habits', habitId);
96+
const habitDoc = await getDoc(habitRef);
97+
if (!habitDoc.exists()) throw new Error('Habit not found');
98+
99+
// Add the user as a participant
100+
await updateDoc(habitRef, {
101+
[`participants.${myId}`]: {
102+
displayName: me.displayName,
103+
username: me.username,
104+
lastActivity: serverTimestamp(),
105+
isOwner: false,
106+
} satisfies Omit<DbParticipantT, 'lastActivity'> & {
107+
lastActivity: FieldValue;
108+
},
109+
});
110+
};
111+
112+
export const modifyHabitEntry = async (
113+
habitId: HabitIdT,
114+
userId: UserIdT,
115+
date: string,
116+
modifiedEntry: HabitEntryT,
117+
): Promise<void> => {
118+
const completionsRef = doc(db, 'habitCompletions', `${habitId}_${userId}`);
119+
const completionsDoc = await getDoc(completionsRef);
120+
121+
// Clean up the entry by removing undefined values
122+
const cleanEntry = {
123+
numberOfCompletions: modifiedEntry.numberOfCompletions,
124+
...(modifiedEntry.note && { note: modifiedEntry.note }),
125+
...(modifiedEntry.image && { image: modifiedEntry.image }),
126+
};
127+
128+
if (!completionsDoc.exists()) {
129+
await setDoc(completionsRef, {
130+
habitId,
131+
userId,
132+
entries: {
133+
[date]: cleanEntry,
134+
},
135+
});
136+
} else {
137+
await updateDoc(completionsRef, {
138+
[`entries.${date}`]: cleanEntry,
139+
});
140+
}
141+
142+
// Update last activity
143+
const habitRef = doc(db, 'habits', habitId);
144+
const habitDoc = await getDoc(habitRef);
145+
if (!habitDoc.exists()) throw new Error('Habit not found');
146+
147+
await updateDoc(habitRef, {
148+
[`participants.${userId}.lastActivity`]: serverTimestamp(),
149+
});
150+
};

src/api/habits/firebase-queries.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {
2+
collection,
3+
doc,
4+
getDoc,
5+
getDocs,
6+
query,
7+
type Timestamp,
8+
where,
9+
} from 'firebase/firestore';
10+
11+
import { habitColors } from '@/ui/colors';
12+
13+
import { db } from '../common/firebase';
14+
import { type UserIdT } from '../users';
15+
import {
16+
type DbHabitT,
17+
type HabitCompletionWithDateInfoT,
18+
type HabitIdT,
19+
type HabitT,
20+
} from './types';
21+
22+
const convertTimestampToDate = (timestamp: Timestamp) => timestamp.toDate();
23+
24+
export const getHabitById = async (id: HabitIdT): Promise<HabitT | null> => {
25+
const habitDoc = await getDoc(doc(db, 'habits', id));
26+
if (!habitDoc.exists()) return null;
27+
28+
const data = habitDoc.data() as DbHabitT;
29+
return {
30+
id: habitDoc.id as HabitIdT,
31+
...data,
32+
createdAt: convertTimestampToDate(data.createdAt as Timestamp),
33+
color: habitColors[data.colorName],
34+
participants: Object.fromEntries(
35+
Object.entries(data.participants).map(([participantId, participant]) => {
36+
if (!participant)
37+
throw new Error('Participant not found for habit ' + id);
38+
39+
const lastActivity = convertTimestampToDate(
40+
participant.lastActivity as Timestamp,
41+
);
42+
43+
return [
44+
participantId,
45+
{
46+
id: participantId as UserIdT,
47+
displayName: participant.displayName,
48+
username: participant.username,
49+
lastActivity,
50+
hasActivityToday:
51+
lastActivity.toLocaleDateString('en-CA') ===
52+
new Date().toLocaleDateString('en-CA'),
53+
isOwner: participant?.isOwner ?? false,
54+
},
55+
];
56+
}),
57+
),
58+
};
59+
};
60+
61+
export const getHabits = async (userId: UserIdT): Promise<HabitT[]> => {
62+
const habitsRef = collection(db, 'habits');
63+
const q = query(habitsRef, where(`participants.${userId}`, '!=', null));
64+
const querySnapshot = await getDocs(q);
65+
66+
return Promise.all(
67+
querySnapshot.docs.map(async (doc) => {
68+
const habit = await getHabitById(doc.id as HabitIdT);
69+
if (!habit) throw new Error('Habit not found');
70+
return habit;
71+
}),
72+
);
73+
};
74+
75+
export const getHabitCompletions = async (
76+
habitId: HabitIdT,
77+
userId: UserIdT,
78+
numDays: number,
79+
): Promise<HabitCompletionWithDateInfoT[]> => {
80+
const completionsDoc = await getDoc(
81+
doc(db, 'habitCompletions', `${habitId}_${userId}`),
82+
);
83+
84+
const entries = completionsDoc.exists() ? completionsDoc.data().entries : {};
85+
86+
const structuredCompletionData: HabitCompletionWithDateInfoT[] = [];
87+
88+
let currentDate = new Date();
89+
// go back to the first day we want to display
90+
currentDate.setDate(currentDate.getDate() - numDays + 1);
91+
// loop through each day and add the completion data for that day to the structured data
92+
for (let i = 0; i < numDays; i++) {
93+
const dateString = currentDate.toLocaleDateString('en-CA');
94+
// if there is no completion data for the current date, default to 0 (no completions that day)
95+
structuredCompletionData.push({
96+
numberOfCompletions: entries?.[dateString]?.numberOfCompletions ?? 0,
97+
dayOfTheMonth: currentDate.getDate(),
98+
dayOfTheWeek: currentDate.toLocaleString('en-US', { weekday: 'short' }),
99+
date: currentDate.toLocaleDateString('en-CA'),
100+
note: entries?.[dateString]?.note,
101+
image: entries?.[dateString]?.image,
102+
});
103+
// move current date ahead 1 day
104+
currentDate.setDate(currentDate.getDate() + 1);
105+
}
106+
107+
return structuredCompletionData;
108+
};
109+
110+
export const getAllUsersHabitCompletions = async (
111+
habitId: HabitIdT,
112+
numDays: number,
113+
): Promise<{ [key: UserIdT]: HabitCompletionWithDateInfoT[] }> => {
114+
const completionsRef = collection(db, 'habitCompletions');
115+
const q = query(completionsRef, where('habitId', '==', habitId));
116+
const querySnapshot = await getDocs(q);
117+
118+
const result: { [key: UserIdT]: HabitCompletionWithDateInfoT[] } = {};
119+
await Promise.all(
120+
querySnapshot.docs.map(async (doc) => {
121+
const userId = doc.data().userId as UserIdT;
122+
result[userId] = await getHabitCompletions(habitId, userId, numDays);
123+
}),
124+
);
125+
126+
return result;
127+
};

0 commit comments

Comments
 (0)