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
10 changes: 9 additions & 1 deletion .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,12 @@ API_URL=https://dummyjson.com/
## TODO: add the variable to your CI and remove it from here, not recommended setting sensitive values on your git repo
SECRET_KEY=my-secret-key
VAR_NUMBER=10 # this is a number variable
VAR_BOOL=true # this is a boolean variable
VAR_BOOL=true # this is a boolean variable

EXPO_PUBLIC_API_URL=http://localhost:5001/habits-together/us-central1/api
EXPO_PUBLIC_FIREBASE_API_KEY=demo-api-key
EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=habits-together.firebaseapp.com
EXPO_PUBLIC_FIREBASE_PROJECT_ID=habits-together
EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=habits-together.appspot.com
EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789
EXPO_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abcdef
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ module.exports = {
ignore: ['/android', '/ios'],
},
],
'max-params': ['error', 3], // Limit the number of parameters in a function to use object instead
'max-params': ['error', 4], // Limit the number of parameters in a function to use object instead
'react/display-name': 'off',
'react/no-inline-styles': 'off',
'react/destructuring-assignment': 'off', // Vscode doesn't support automatically destructuring, it's a pain to add a new variable
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"expo-splash-screen": "0.27.7",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"firebase": "^11.2.0",
"lodash.memoize": "^4.1.2",
"lottie-react-native": "6.7.0",
"lucide-react-native": "^0.441.0",
Expand Down
703 changes: 703 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions src/api/common/firebase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { initializeApp } from 'firebase/app';
import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore';
import { connectFunctionsEmulator, getFunctions } from 'firebase/functions';

const firebaseConfig = {
apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
};

// Initialize Firebase
export const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);

// Connect to emulator in development
if (__DEV__) {
connectFirestoreEmulator(db, '127.0.0.1', 8080);
}

// Connect to Functions emulator
const functions = getFunctions();
if (process.env.NODE_ENV === 'development') {
connectFunctionsEmulator(functions, '127.0.0.1', 5001);
}
150 changes: 150 additions & 0 deletions src/api/habits/firebase-mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
addDoc,
collection,
deleteDoc,
doc,
type FieldValue,
getDoc,
serverTimestamp,
setDoc,
updateDoc,
} from 'firebase/firestore';

import { type habitColors } from '@/ui/colors';

import { db } from '../common/firebase';
import { getUserById, type UserIdT } from '../users';
import {
type DbHabitT,
type DbParticipantT,
type HabitCreationT,
type HabitEntryT,
type HabitIdT,
} from './types';

type ColorName = keyof typeof habitColors;

export const createHabit = async (
habitCreationInfo: HabitCreationT,
): Promise<DbHabitT> => {
const myId = '1' as UserIdT; // TODO: Get from auth context

const newHabit = {
title: habitCreationInfo.title,
description: habitCreationInfo.description,
colorName: habitCreationInfo.colorName as ColorName,
icon: habitCreationInfo.icon,
settings: {
allowMultipleCompletions: habitCreationInfo.allowMultipleCompletions,
},
participants: {
[myId]: {
displayName: 'Alex Chen', // TODO: Get from auth context
username: 'alexchen',
lastActivity: serverTimestamp(),
isOwner: true,
} satisfies Omit<DbParticipantT, 'lastActivity'> & {
lastActivity: FieldValue;
},
},
createdAt: serverTimestamp(),
} satisfies Omit<DbHabitT, 'lastActivity' | 'createdAt'> & {
createdAt: FieldValue;
};

const docRef = await addDoc(collection(db, 'habits'), newHabit);
const created = await getDoc(docRef);
if (!created.exists()) throw new Error('Failed to create habit');
return created.data() as DbHabitT;
};

export const deleteHabit = async (habitId: HabitIdT): Promise<void> => {
await deleteDoc(doc(db, 'habits', habitId));
};

export const editHabit = async (
habitId: HabitIdT,
newHabitInfo: HabitCreationT,
): Promise<DbHabitT> => {
const habitRef = doc(db, 'habits', habitId);
const habitDoc = await getDoc(habitRef);
if (!habitDoc.exists()) throw new Error('Habit not found');

const data = habitDoc.data() as DbHabitT;
const updatedHabit = {
...data,
title: newHabitInfo.title,
description: newHabitInfo.description,
colorName: newHabitInfo.colorName as ColorName,
icon: newHabitInfo.icon,
settings: {
...data.settings,
allowMultipleCompletions: newHabitInfo.allowMultipleCompletions,
},
} satisfies DbHabitT;

await updateDoc(habitRef, updatedHabit);
return updatedHabit;
};

export const acceptHabitInvite = async (habitId: HabitIdT): Promise<void> => {
const myId = '1' as UserIdT; // TODO: Get from auth context
const me = await getUserById(myId);
if (!me) throw new Error('User not found');

const habitRef = doc(db, 'habits', habitId);
const habitDoc = await getDoc(habitRef);
if (!habitDoc.exists()) throw new Error('Habit not found');

// Add the user as a participant
await updateDoc(habitRef, {
[`participants.${myId}`]: {
displayName: me.displayName,
username: me.username,
lastActivity: serverTimestamp(),
isOwner: false,
} satisfies Omit<DbParticipantT, 'lastActivity'> & {
lastActivity: FieldValue;
},
});
};

export const modifyHabitEntry = async (
habitId: HabitIdT,
userId: UserIdT,
date: string,
modifiedEntry: HabitEntryT,
): Promise<void> => {
const completionsRef = doc(db, 'habitCompletions', `${habitId}_${userId}`);
const completionsDoc = await getDoc(completionsRef);

// Clean up the entry by removing undefined values
const cleanEntry = {
numberOfCompletions: modifiedEntry.numberOfCompletions,
...(modifiedEntry.note && { note: modifiedEntry.note }),
...(modifiedEntry.image && { image: modifiedEntry.image }),
};

if (!completionsDoc.exists()) {
await setDoc(completionsRef, {
habitId,
userId,
entries: {
[date]: cleanEntry,
},
});
} else {
await updateDoc(completionsRef, {
[`entries.${date}`]: cleanEntry,
});
}

// Update last activity
const habitRef = doc(db, 'habits', habitId);
const habitDoc = await getDoc(habitRef);
if (!habitDoc.exists()) throw new Error('Habit not found');

await updateDoc(habitRef, {
[`participants.${userId}.lastActivity`]: serverTimestamp(),
});
};
127 changes: 127 additions & 0 deletions src/api/habits/firebase-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
collection,
doc,
getDoc,
getDocs,
query,
type Timestamp,
where,
} from 'firebase/firestore';

import { habitColors } from '@/ui/colors';

import { db } from '../common/firebase';
import { type UserIdT } from '../users';
import {
type DbHabitT,
type HabitCompletionWithDateInfoT,
type HabitIdT,
type HabitT,
} from './types';

const convertTimestampToDate = (timestamp: Timestamp) => timestamp.toDate();

export const getHabitById = async (id: HabitIdT): Promise<HabitT | null> => {
const habitDoc = await getDoc(doc(db, 'habits', id));
if (!habitDoc.exists()) return null;

const data = habitDoc.data() as DbHabitT;
return {
id: habitDoc.id as HabitIdT,
...data,
createdAt: convertTimestampToDate(data.createdAt as Timestamp),
color: habitColors[data.colorName],
participants: Object.fromEntries(
Object.entries(data.participants).map(([participantId, participant]) => {
if (!participant)
throw new Error('Participant not found for habit ' + id);

const lastActivity = convertTimestampToDate(
participant.lastActivity as Timestamp,
);

return [
participantId,
{
id: participantId as UserIdT,
displayName: participant.displayName,
username: participant.username,
lastActivity,
hasActivityToday:
lastActivity.toLocaleDateString('en-CA') ===
new Date().toLocaleDateString('en-CA'),
isOwner: participant?.isOwner ?? false,
},
];
}),
),
};
};

export const getHabits = async (userId: UserIdT): Promise<HabitT[]> => {
const habitsRef = collection(db, 'habits');
const q = query(habitsRef, where(`participants.${userId}`, '!=', null));
const querySnapshot = await getDocs(q);

return Promise.all(
querySnapshot.docs.map(async (doc) => {
const habit = await getHabitById(doc.id as HabitIdT);
if (!habit) throw new Error('Habit not found');
return habit;
}),
);
};

export const getHabitCompletions = async (
habitId: HabitIdT,
userId: UserIdT,
numDays: number,
): Promise<HabitCompletionWithDateInfoT[]> => {
const completionsDoc = await getDoc(
doc(db, 'habitCompletions', `${habitId}_${userId}`),
);

const entries = completionsDoc.exists() ? completionsDoc.data().entries : {};

const structuredCompletionData: HabitCompletionWithDateInfoT[] = [];

let currentDate = new Date();
// go back to the first day we want to display
currentDate.setDate(currentDate.getDate() - numDays + 1);
// loop through each day and add the completion data for that day to the structured data
for (let i = 0; i < numDays; i++) {
const dateString = currentDate.toLocaleDateString('en-CA');
// if there is no completion data for the current date, default to 0 (no completions that day)
structuredCompletionData.push({
numberOfCompletions: entries?.[dateString]?.numberOfCompletions ?? 0,
dayOfTheMonth: currentDate.getDate(),
dayOfTheWeek: currentDate.toLocaleString('en-US', { weekday: 'short' }),
date: currentDate.toLocaleDateString('en-CA'),
note: entries?.[dateString]?.note,
image: entries?.[dateString]?.image,
});
// move current date ahead 1 day
currentDate.setDate(currentDate.getDate() + 1);
}

return structuredCompletionData;
};

export const getAllUsersHabitCompletions = async (
habitId: HabitIdT,
numDays: number,
): Promise<{ [key: UserIdT]: HabitCompletionWithDateInfoT[] }> => {
const completionsRef = collection(db, 'habitCompletions');
const q = query(completionsRef, where('habitId', '==', habitId));
const querySnapshot = await getDocs(q);

const result: { [key: UserIdT]: HabitCompletionWithDateInfoT[] } = {};
await Promise.all(
querySnapshot.docs.map(async (doc) => {
const userId = doc.data().userId as UserIdT;
result[userId] = await getHabitCompletions(habitId, userId, numDays);
}),
);

return result;
};
Loading
Loading