feat: add basic data architecture

This commit is contained in:
Théo LUDWIG 2024-03-15 22:48:28 +01:00
parent f959f69de6
commit ac6e66e0b5
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
37 changed files with 4365 additions and 2144 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (`hostname -I` on Linux)
EXPO_PUBLIC_SUPABASE_ANON_KEY=''

1
.gitignore vendored
View File

@ -31,6 +31,7 @@ yarn-error.*
# local env files # local env files
.env*.local .env*.local
.env
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo

View File

@ -6,9 +6,6 @@ Application mobile en [React Native](https://reactnative.dev/) pour le projet du
Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours. Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours.
- [Sujet](./docs/SUJET.md)
- [Cahier des charges](./docs/CAHIER-DES-CHARGES.md)
### Membres du Groupe 7 ### Membres du Groupe 7
- [Théo LUDWIG](https://git.unistra.fr/t.ludwig) - [Théo LUDWIG](https://git.unistra.fr/t.ludwig)
@ -16,6 +13,22 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
- [Maxime RUMPLER](https://git.unistra.fr/m.rumpler) - [Maxime RUMPLER](https://git.unistra.fr/m.rumpler)
- [Maxime RICHARD](https://git.unistra.fr/maximerichard) - [Maxime RICHARD](https://git.unistra.fr/maximerichard)
### Documentation
- [Sujet du projet](./docs/SUJET.md)
- [Cahier des charges](./docs/CAHIER-DES-CHARGES.md)
- [Modèle Logique des Données (MLD)](./docs/MLD.md)
- [Clean Architecture](./docs/ARCHITECTURE.md)
- [Conventions développement informatique](./docs/CONVENTIONS.md)
#### Principaux Outils Informatiques Utilisés
- [React Native](https://reactnative.dev/) + [Expo](https://expo.io/): Framework pour le développement d'applications mobiles.
- [TypeScript](https://www.typescriptlang.org/): Langage de programmation.
- [React Native Paper](https://callstack.github.io/react-native-paper/): Bibliothèque de composants pour React Native.
<!-- - [WatermelonDB](https://nozbe.github.io/WatermelonDB/): Base de données locale, pour permettre une utilisation hors-ligne de l'application. -->
- [Supabase](https://supabase.io/): Backend, serveur d'API pour le stockage des données.
## Développement du projet en local ## Développement du projet en local
### Prérequis ### Prérequis
@ -23,6 +36,7 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
- [Node.js](https://nodejs.org/) >= 20.0.0 - [Node.js](https://nodejs.org/) >= 20.0.0
- [npm](https://www.npmjs.com/) >= 10.0.0 - [npm](https://www.npmjs.com/) >= 10.0.0
- [Expo Go](https://expo.io/client) - [Expo Go](https://expo.io/client)
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
### Installation ### Installation
@ -30,6 +44,12 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
# Cloner le projet # Cloner le projet
git clone git@git.unistra.fr:rrll/p61-project.git git clone git@git.unistra.fr:rrll/p61-project.git
# Se déplacer dans le répertoire du projet
cd p61-project
# Configurer les variables d'environnement
cp .env.example .env
# Installer les dépendances # Installer les dépendances
npm clean-install npm clean-install
``` ```
@ -40,37 +60,10 @@ npm clean-install
npm run start npm run start
``` ```
### Linting/Formatting/Tests ### Lancer Supabase (facultatif)
Le code est formaté grâce à [Prettier](https://prettier.io/) et vérifié grâce à [ESLint](https://eslint.org/) et à [TypeScript](https://www.typescriptlang.org/) pour s'assurer que le code respecte les bonnes pratiques de développement, et détecter en amont les possibles erreurs. Ce n'est pas strictement nécessaire pour le développement de l'application (même si recommandé), de lancer [Supabase](https://supabase.io/) en local, car l'application est déjà déployée sur un serveur [Supabase](https://supabase.io/) en production.
Nous utilisons également [Jest](https://jestjs.io/) pour les tests automatisés.
```sh ```sh
# Lint npm run supabase
npm run lint:prettier
npm run lint:eslint
npm run lint:typescript
# Test
npm run test
``` ```
Une pipeline CI ([`.gitlab-ci.yml`](.gitlab-ci.yml)) est en place pour vérifier que le code respecte ces bonnes pratiques et que les tests passent.
### GitFlow
Le projet suit la convention [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) reposant sur 2 branches principales:
- `main` (ou `master`): Contient le code de la dernière version stable et déployé en production.
- `develop`: Contient le code en cours de développement. Les nouvelles fonctionnalités et les correctifs de bugs sont fusionnés ici.
### Convention des commits
Les commits respectent la convention [Conventional Commits](https://www.conventionalcommits.org/) et [Semantic Versioning](https://semver.org/) pour la gestion des versions et des releases en fonction des commits.
Les commits doivent être **atomiques** c'est à dire qu'il respecte 3 règles:
- Ne concerne qu'un seul sujet (une fonctionnalité, une correction de bug, etc.)
- Doit avoir un message clair et concis
- Ne doit pas rendre de dépôt "incohérent" (bloque la CI du projet)

View File

@ -5,7 +5,7 @@
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "myapp", "scheme": "p61-project",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"splash": { "splash": {
"image": "./assets/images/splashscreen.jpg", "image": "./assets/images/splashscreen.jpg",

View File

@ -1,170 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<HomePage /> renders correctly 1`] = `
<RNCSafeAreaView
edges={
{
"bottom": "additive",
"left": "additive",
"right": "additive",
"top": "additive",
}
}
style={
{
"alignItems": "center",
"flex": 1,
"justifyContent": "center",
}
}
>
<View
collapsable={false}
style={
{
"backgroundColor": "rgba(103, 80, 164, 1)",
"borderRadius": 20,
"shadowColor": "#000",
"shadowOffset": {
"height": 0,
"width": 0,
},
"shadowOpacity": 0,
"shadowRadius": 0,
}
}
testID="button-container-outer-layer"
>
<View
collapsable={false}
style={
{
"backgroundColor": "rgba(103, 80, 164, 1)",
"borderColor": "transparent",
"borderRadius": 20,
"borderStyle": "solid",
"borderWidth": 0,
"flex": undefined,
"minWidth": 64,
"shadowColor": "#000",
"shadowOffset": {
"height": 0,
"width": 0,
},
"shadowOpacity": 0,
"shadowRadius": 0,
}
}
testID="button-container"
>
<View
accessibilityRole="button"
accessibilityState={
{
"busy": undefined,
"checked": undefined,
"disabled": false,
"expanded": undefined,
"selected": undefined,
}
}
accessibilityValue={
{
"max": undefined,
"min": undefined,
"now": undefined,
"text": undefined,
}
}
accessible={true}
collapsable={false}
focusable={true}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
[
{
"overflow": "hidden",
},
{
"borderRadius": 20,
},
]
}
testID="button"
>
<View
style={
[
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "center",
},
undefined,
]
}
>
<Text
numberOfLines={1}
selectable={false}
style={
[
{
"textAlign": "left",
},
{
"color": "rgba(28, 27, 31, 1)",
"writingDirection": "ltr",
},
[
{
"fontFamily": "System",
"fontSize": 14,
"fontWeight": "500",
"letterSpacing": 0.1,
"lineHeight": 20,
},
[
{
"marginHorizontal": 16,
"marginVertical": 9,
"textAlign": "center",
},
false,
{
"marginHorizontal": 24,
"marginVertical": 10,
},
undefined,
false,
{
"color": "rgba(255, 255, 255, 1)",
"fontFamily": "System",
"fontSize": 14,
"fontWeight": "500",
"letterSpacing": 0.1,
"lineHeight": 20,
},
undefined,
],
],
]
}
testID="button-text"
>
Press me
</Text>
</View>
</View>
</View>
</View>
</RNCSafeAreaView>
`;

View File

@ -1,14 +0,0 @@
import renderer from "react-test-renderer"
import HomePage from "@/app/(pages)/index"
describe("<HomePage />", () => {
beforeEach(async () => {
jest.clearAllMocks()
})
it("renders correctly", () => {
const tree = renderer.create(<HomePage />).toJSON()
expect(tree).toMatchSnapshot()
})
})

View File

@ -1,18 +1,24 @@
import { StyleSheet } from "react-native" import { StyleSheet, Text, View } from "react-native"
import { Button } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context" import { SafeAreaView } from "react-native-safe-area-context"
import { useHabitsTracker } from "@/contexts/HabitsTracker"
const HomePage: React.FC = () => { const HomePage: React.FC = () => {
const { habitsTrackerPresenterState } = useHabitsTracker()
const { habitsTracker } = habitsTrackerPresenterState
const { habitProgressHistories } = habitsTracker
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<Button {habitProgressHistories.map((progressHistory) => {
mode="contained" const { habit } = progressHistory
onPress={() => {
return console.log("Pressed") return (
}} <View key={habit.id}>
> <Text>{habit.name}</Text>
Press me </View>
</Button> )
})}
</SafeAreaView> </SafeAreaView>
) )
} }

View File

@ -11,6 +11,7 @@ import { StatusBar } from "expo-status-bar"
import CanterburyFont from "../assets/fonts/Canterbury.ttf" import CanterburyFont from "../assets/fonts/Canterbury.ttf"
import GeoramFont from "../assets/fonts/Georama-Black.ttf" import GeoramFont from "../assets/fonts/Georama-Black.ttf"
import SpaceMonoFont from "../assets/fonts/SpaceMono-Regular.ttf" import SpaceMonoFont from "../assets/fonts/SpaceMono-Regular.ttf"
import { HabitsTrackerProvider } from "@/contexts/HabitsTracker"
export { ErrorBoundary } from "expo-router" export { ErrorBoundary } from "expo-router"
@ -48,26 +49,28 @@ const RootLayout: React.FC = () => {
} }
return ( return (
<PaperProvider <HabitsTrackerProvider>
theme={{ <PaperProvider
...DefaultTheme, theme={{
colors: { ...DefaultTheme,
...DefaultTheme.colors, colors: {
primary: "#f57c00", ...DefaultTheme.colors,
secondary: "#fbc02d", primary: "#f57c00",
}, secondary: "#fbc02d",
}} },
>
<Stack
screenOptions={{
headerShown: false,
}} }}
> >
<Stack.Screen name="(pages)" /> <Stack
</Stack> screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(pages)" />
</Stack>
<StatusBar style="dark" /> <StatusBar style="dark" />
</PaperProvider> </PaperProvider>
</HabitsTrackerProvider>
) )
} }

View File

@ -0,0 +1,54 @@
import { createContext, useContext, useEffect } from "react"
import type {
HabitsTrackerPresenter,
HabitsTrackerPresenterState,
} from "@/data/infrastructure/presenters/HabitsTrackerPresenter"
import { usePresenterState } from "@/hooks/usePresenterState"
import { habitsTrackerPresenter } from "@/data/infrastructure"
export interface HabitsTrackerContextValue {
habitsTrackerPresenterState: HabitsTrackerPresenterState
habitsTrackerPresenter: HabitsTrackerPresenter
}
const defaultHabitsTrackerContextValue = {} as HabitsTrackerContextValue
const HabitsTrackerContext = createContext<HabitsTrackerContextValue>(
defaultHabitsTrackerContextValue,
)
interface HabitsTrackerProviderProps extends React.PropsWithChildren {}
export const HabitsTrackerProvider: React.FC<HabitsTrackerProviderProps> = (
props,
) => {
const { children } = props
useEffect(() => {
habitsTrackerPresenter
.retrieveHabitsTracker({ userId: "1" })
.catch((error) => {
console.error(error)
})
}, [])
const habitsTrackerPresenterState = usePresenterState(habitsTrackerPresenter)
return (
<HabitsTrackerContext.Provider
value={{ habitsTrackerPresenterState, habitsTrackerPresenter }}
>
{children}
</HabitsTrackerContext.Provider>
)
}
export const useHabitsTracker = (): HabitsTrackerContextValue => {
const context = useContext(HabitsTrackerContext)
if (context === defaultHabitsTrackerContextValue) {
throw new Error(
"`useHabitsTracker` must be used within a `HabitsTrackerProvider`.",
)
}
return context
}

View File

@ -0,0 +1,108 @@
export const GOAL_FREQUENCIES = ["daily", "weekly", "monthly"] as const
export type GoalFrequency = (typeof GOAL_FREQUENCIES)[number]
export const GOAL_TYPES = ["numeric", "boolean"] as const
export type GoalType = (typeof GOAL_TYPES)[number]
interface GoalBase {
frequency: GoalFrequency
}
export abstract class Goal implements GoalBase {
public frequency: GoalBase["frequency"]
public abstract readonly type: GoalType
public constructor(options: GoalBase) {
const { frequency } = options
this.frequency = frequency
}
public static isNumeric(goal: Goal): goal is GoalNumeric {
return goal.type === "numeric"
}
public isNumeric(): this is GoalNumeric {
return Goal.isNumeric(this)
}
public static isBoolean(goal: Goal): goal is GoalBoolean {
return goal.type === "boolean"
}
public isBoolean(): this is GoalBoolean {
return Goal.isBoolean(this)
}
}
interface GoalProgressBase {
goal: Goal
}
export abstract class GoalProgress implements GoalProgressBase {
public abstract readonly goal: Goal
public abstract isCompleted(): boolean
}
interface GoalNumericOptions extends GoalBase {
target: {
value: number
unit: string
}
}
export class GoalNumeric extends Goal {
public readonly type = "numeric"
public target: {
value: number
unit: string
}
public constructor(options: GoalNumericOptions) {
super(options)
const { target } = options
this.target = target
}
}
interface GoalNumericProgressOptions extends GoalProgressBase {
goal: GoalNumeric
progress: number
}
export class GoalNumericProgress extends GoalProgress {
public readonly goal: GoalNumeric
public readonly progress: number
public constructor(options: GoalNumericProgressOptions) {
const { goal, progress } = options
super()
this.goal = goal
this.progress = progress
}
public override isCompleted(): boolean {
return this.progress >= this.goal.target.value
}
public progressRatio(): number {
return this.goal.target.value <= 0
? 0
: this.progress / this.goal.target.value
}
}
export class GoalBoolean extends Goal {
public readonly type = "boolean"
}
interface GoalBooleanProgressOptions extends GoalProgressBase {
goal: GoalBoolean
progress: boolean
}
export class GoalBooleanProgress extends GoalProgress {
public readonly goal: GoalBoolean
public progress: boolean
public constructor(options: GoalBooleanProgressOptions) {
const { goal, progress } = options
super()
this.goal = goal
this.progress = progress
}
public override isCompleted(): boolean {
return this.progress
}
}

View File

@ -0,0 +1,36 @@
import type { Goal } from "./Goal"
import type { User } from "./User"
import type { EntityOptions } from "./_Entity"
import { Entity } from "./_Entity"
export interface HabitOptions extends EntityOptions {
userId: User["id"]
name: string
color: string
icon: string
goal: Goal
startDate: Date
endDate?: Date
}
export class Habit extends Entity implements HabitOptions {
public userId: HabitOptions["userId"]
public name: HabitOptions["name"]
public color: HabitOptions["color"]
public icon: HabitOptions["icon"]
public goal: HabitOptions["goal"]
public startDate: HabitOptions["startDate"]
public endDate?: HabitOptions["endDate"]
public constructor(options: HabitOptions) {
const { id, userId, name, color, icon, goal, startDate, endDate } = options
super({ id })
this.userId = userId
this.name = name
this.color = color
this.icon = icon
this.goal = goal
this.startDate = startDate
this.endDate = endDate
}
}

View File

@ -0,0 +1,24 @@
import type { GoalProgress } from "./Goal"
import type { Habit } from "./Habit"
import type { EntityOptions } from "./_Entity"
import { Entity } from "./_Entity"
export interface HabitProgressOptions extends EntityOptions {
habitId: Habit["id"]
goalProgress: GoalProgress
date: Date
}
export class HabitProgress extends Entity implements HabitProgressOptions {
public habitId: HabitProgressOptions["habitId"]
public goalProgress: HabitProgressOptions["goalProgress"]
public date: HabitProgressOptions["date"]
public constructor(options: HabitProgressOptions) {
const { id, habitId, goalProgress, date } = options
super({ id })
this.habitId = habitId
this.goalProgress = goalProgress
this.date = date
}
}

View File

@ -0,0 +1,18 @@
import type { Habit } from "./Habit"
import type { HabitProgress } from "./HabitProgress"
export interface HabitProgressHistoryOptions {
habit: Habit
habitProgresses: HabitProgress[]
}
export class HabitProgressHistory implements HabitProgressHistoryOptions {
public habit: Habit
public habitProgresses: HabitProgress[]
public constructor(options: HabitProgressHistoryOptions) {
const { habit, habitProgresses } = options
this.habit = habit
this.habitProgresses = habitProgresses
}
}

View File

@ -0,0 +1,18 @@
import type { HabitProgressHistory } from "./HabitProgressHistory"
export interface HabitsTrackerOptions {
habitProgressHistories: HabitProgressHistory[]
}
export class HabitsTracker implements HabitsTrackerOptions {
public habitProgressHistories: HabitProgressHistory[]
public constructor(options: HabitsTrackerOptions) {
const { habitProgressHistories } = options
this.habitProgressHistories = habitProgressHistories
}
public static default(): HabitsTracker {
return new HabitsTracker({ habitProgressHistories: [] })
}
}

View File

@ -0,0 +1,19 @@
import type { EntityOptions } from "./_Entity"
import { Entity } from "./_Entity"
export interface UserOptions extends EntityOptions {
email: string
displayName: string
}
export class User extends Entity implements UserOptions {
public email: UserOptions["email"]
public displayName: UserOptions["displayName"]
public constructor(options: UserOptions) {
const { id, email, displayName } = options
super({ id })
this.email = email
this.displayName = displayName
}
}

View File

@ -0,0 +1,16 @@
export interface EntityOptions {
id: string
}
export abstract class Entity implements EntityOptions {
public readonly id: string
public constructor(options: EntityOptions) {
const { id } = options
this.id = id
}
// public equals(entity: Entity): boolean {
// return entity.id === this.id
// }
}

View File

@ -0,0 +1,10 @@
import type { Habit } from "../entities/Habit"
import type { HabitProgress } from "../entities/HabitProgress"
export interface GetHabitProgressesOptions {
habit: Habit
}
export interface GetHabitProgressesRepository {
execute: (options: GetHabitProgressesOptions) => Promise<HabitProgress[]>
}

View File

@ -0,0 +1,10 @@
import type { Habit } from "../entities/Habit"
import type { User } from "../entities/User"
export interface GetHabitsByUserIdOptions {
userId: User["id"]
}
export interface GetHabitsByUserIdRepository {
execute: (options: GetHabitsByUserIdOptions) => Promise<Habit[]>
}

View File

@ -0,0 +1,45 @@
import { HabitProgressHistory } from "../entities/HabitProgressHistory"
import { HabitsTracker } from "../entities/HabitsTracker"
import type { User } from "../entities/User"
import type { GetHabitProgressesRepository } from "../repositories/GetHabitProgresses"
import type { GetHabitsByUserIdRepository } from "../repositories/GetHabitsByUserId"
export interface RetrieveHabitsTrackerUseCaseDependencyOptions {
getHabitsByUserIdRepository: GetHabitsByUserIdRepository
getHabitProgressesRepository: GetHabitProgressesRepository
}
export interface RetrieveHabitsTrackerUseCaseOptions {
userId: User["id"]
}
export class RetrieveHabitsTrackerUseCase
implements RetrieveHabitsTrackerUseCaseDependencyOptions
{
public getHabitsByUserIdRepository: GetHabitsByUserIdRepository
public getHabitProgressesRepository: GetHabitProgressesRepository
public constructor(options: RetrieveHabitsTrackerUseCaseDependencyOptions) {
this.getHabitsByUserIdRepository = options.getHabitsByUserIdRepository
this.getHabitProgressesRepository = options.getHabitProgressesRepository
}
public async execute(
options: RetrieveHabitsTrackerUseCaseOptions,
): Promise<HabitsTracker> {
const { userId } = options
const habits = await this.getHabitsByUserIdRepository.execute({ userId })
const habitProgressHistories = await Promise.all(
habits.map(async (habit) => {
const habitProgresses = await this.getHabitProgressesRepository.execute(
{
habit,
},
)
return new HabitProgressHistory({ habit, habitProgresses })
}),
)
const habitsTracker = new HabitsTracker({ habitProgressHistories })
return habitsTracker
}
}

View File

@ -0,0 +1,34 @@
// export const taskRepository = new TaskLocalStorageRepository()
// export const taskService = new TaskService(taskRepository)
// export const taskPresenter = new TaskPresenter(taskService)
import { RetrieveHabitsTrackerUseCase } from "../domain/use-cases/RetrieveHabitsTracker"
import { HabitsTrackerPresenter } from "./presenters/HabitsTrackerPresenter"
import { GetHabitProgressesSupabaseRepository } from "./repositories/supabase/lib/GetHabitProgresses"
import { GetHabitsByUserIdSupabaseRepository } from "./repositories/supabase/lib/GetHabitsByUserId"
import { supabaseClient } from "./repositories/supabase/supabase"
/**
* Repositories
*/
const getHabitProgressesRepository = new GetHabitProgressesSupabaseRepository({
supabaseClient,
})
const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
supabaseClient,
})
/**
* Use Cases
*/
const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
getHabitProgressesRepository,
getHabitsByUserIdRepository,
})
/**
* Presenters
*/
export const habitsTrackerPresenter = new HabitsTrackerPresenter({
retrieveHabitsTrackerUseCase,
})

View File

@ -0,0 +1,38 @@
import { HabitsTracker } from "@/data/domain/entities/HabitsTracker"
import { Presenter } from "./_Presenter"
import type {
RetrieveHabitsTrackerUseCase,
RetrieveHabitsTrackerUseCaseOptions,
} from "@/data/domain/use-cases/RetrieveHabitsTracker"
export interface HabitsTrackerPresenterState {
habitsTracker: HabitsTracker
}
export interface HabitsTrackerPresenterOptions {
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
}
export class HabitsTrackerPresenter
extends Presenter<HabitsTrackerPresenterState>
implements HabitsTrackerPresenterOptions
{
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
public constructor(options: HabitsTrackerPresenterOptions) {
const { retrieveHabitsTrackerUseCase } = options
const habitsTracker = HabitsTracker.default()
super({ habitsTracker })
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
}
public async retrieveHabitsTracker(
options: RetrieveHabitsTrackerUseCaseOptions,
): Promise<void> {
const habitsTracker =
await this.retrieveHabitsTrackerUseCase.execute(options)
this.setState((state) => {
state.habitsTracker = habitsTracker
})
}
}

View File

@ -0,0 +1,50 @@
import { produce } from "immer"
type Listener<S> = (state: S) => void
type SetStateAction<S> = (state: S) => void
export abstract class Presenter<S> {
private _state: S
private readonly _initialState: S
private readonly _listeners: Array<Listener<S>>
public constructor(initialState: S) {
this._state = initialState
this._initialState = initialState
this._listeners = []
}
public get initialState(): S {
return this._initialState
}
/**
* @description Set the state of the presenter.
* @param action A function that receives the current state and can update it by mutating it.
* @returns The new state.
*/
public setState(action: SetStateAction<S>): S {
this._state = produce(this._state, action)
this.notifyListeners()
return this._state
}
public subscribe(listener: Listener<S>): void {
this._listeners.push(listener)
}
public unsubscribe(listener: Listener<S>): void {
const listenerIndex = this._listeners.indexOf(listener)
const listenerFound = listenerIndex !== -1
if (listenerFound) {
this._listeners.splice(listenerIndex, 1)
}
}
private notifyListeners(): void {
for (const listener of this._listeners) {
listener(this._state)
}
}
}

View File

@ -0,0 +1,4 @@
# Supabase
.branches
.temp
.env

View File

@ -0,0 +1,161 @@
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "p61-project"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. public and storage are always included.
schemas = ["public", "storage", "graphql_public"]
# Extra schemas to add to the search_path of every request. public is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv6)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = true
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }} ."
# Use pre-defined map of phone number to OTP for testing.
[auth.sms.test_otp]
# 4152127777 = "123456"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
[auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
[analytics]
enabled = false
port = 54327
vector_port = 54328
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"

View File

@ -0,0 +1,49 @@
import type { GetHabitProgressesRepository } from "@/data/domain/repositories/GetHabitProgresses"
import { SupabaseRepository } from "./_SupabaseRepository"
import { HabitProgress } from "@/data/domain/entities/HabitProgress"
import type { GoalProgress } from "@/data/domain/entities/Goal"
import {
GoalBooleanProgress,
GoalNumericProgress,
} from "@/data/domain/entities/Goal"
export class GetHabitProgressesSupabaseRepository
extends SupabaseRepository
implements GetHabitProgressesRepository
{
execute: GetHabitProgressesRepository["execute"] = async (options) => {
const { habit } = options
const { data, error } = await this.supabaseClient
.from("habits_progresses")
.select("*")
.eq("habit_id", habit.id)
if (error != null) {
throw new Error(error.message)
}
const habitProgresses = data.map((item) => {
let goalProgress: GoalProgress | null = null
if (habit.goal.isNumeric()) {
goalProgress = new GoalNumericProgress({
goal: habit.goal,
progress: item.goal_progress,
})
} else if (habit.goal.isBoolean()) {
goalProgress = new GoalBooleanProgress({
goal: habit.goal,
progress: item.goal_progress === 1,
})
}
if (goalProgress == null) {
throw new Error("Goal progress is null.")
}
const habitProgress = new HabitProgress({
id: item.id.toString(),
habitId: item.habit_id.toString(),
goalProgress,
date: new Date(item.date),
})
return habitProgress
})
return habitProgresses
}
}

View File

@ -0,0 +1,50 @@
import type { GetHabitsByUserIdRepository } from "@/data/domain/repositories/GetHabitsByUserId"
import { SupabaseRepository } from "./_SupabaseRepository"
import { Habit } from "@/data/domain/entities/Habit"
import type { Goal } from "@/data/domain/entities/Goal"
import { GoalBoolean, GoalNumeric } from "@/data/domain/entities/Goal"
export class GetHabitsByUserIdSupabaseRepository
extends SupabaseRepository
implements GetHabitsByUserIdRepository
{
// execute: GetHabitsByUserIdRepository["execute"] = async (options) => {
// const { userId } = options
// const { data, error } = await this.supabaseClient
// .from("habits")
// .select("*")
// .eq("user_id", userId)
execute: GetHabitsByUserIdRepository["execute"] = async () => {
const { data, error } = await this.supabaseClient.from("habits").select("*")
if (error != null) {
throw new Error(error.message)
}
return data.map((item) => {
let goal: Goal
if (item.goal_target != null && item.goal_target_unit != null) {
goal = new GoalNumeric({
frequency: item.goal_frequency,
target: {
value: item.goal_target,
unit: item.goal_target_unit,
},
})
} else {
goal = new GoalBoolean({
frequency: item.goal_frequency,
})
}
const habit = new Habit({
id: item.id.toString(),
name: item.name,
color: item.color,
icon: item.icon,
userId: item.user_id.toString(),
startDate: new Date(item.start_date),
endDate: item.end_date != null ? new Date(item.end_date) : undefined,
goal,
})
return habit
})
}
}

View File

@ -0,0 +1,15 @@
import type { SupabaseClient } from "@supabase/supabase-js"
import type { Database } from "../supabase-types"
export interface SupabaseRepositoryOptions {
supabaseClient: SupabaseClient<Database>
}
export abstract class SupabaseRepository implements SupabaseRepositoryOptions {
public supabaseClient: SupabaseRepositoryOptions["supabaseClient"]
public constructor(options: SupabaseRepositoryOptions) {
this.supabaseClient = options.supabaseClient
}
}

View File

@ -0,0 +1,304 @@
CREATE TYPE "public"."goal_frequency" AS enum ('daily', 'weekly', 'monthly');
CREATE TABLE "public"."habits" (
"id" bigint generated by DEFAULT AS identity NOT NULL,
"name" text NOT NULL,
"color" text NOT NULL,
"icon" text NOT NULL,
"start_date" timestamp WITH time zone NOT NULL DEFAULT NOW(),
"end_date" timestamp WITH time zone,
"goal_frequency" goal_frequency NOT NULL,
"goal_target" bigint,
"goal_target_unit" text,
"user_id" uuid NOT NULL DEFAULT gen_random_uuid()
);
ALTER TABLE
"public"."habits" enable ROW LEVEL SECURITY;
CREATE TABLE "public"."habits_progresses" (
"id" bigint generated by DEFAULT AS identity NOT NULL,
"habit_id" bigint NOT NULL,
"date" timestamp WITH time zone NOT NULL DEFAULT NOW(),
"goal_progress" bigint NOT NULL
);
ALTER TABLE
"public"."habits_progresses" enable ROW LEVEL SECURITY;
CREATE UNIQUE INDEX habits_id_key ON public.habits USING btree (id);
CREATE UNIQUE INDEX habits_pkey ON public.habits USING btree (id);
CREATE UNIQUE INDEX habits_progresses_id_key ON public.habits_progresses USING btree (id);
CREATE UNIQUE INDEX habits_progresses_pkey ON public.habits_progresses USING btree (id);
ALTER TABLE
"public"."habits"
ADD
CONSTRAINT "habits_pkey" PRIMARY KEY USING INDEX "habits_pkey";
ALTER TABLE
"public"."habits_progresses"
ADD
CONSTRAINT "habits_progresses_pkey" PRIMARY KEY USING INDEX "habits_progresses_pkey";
ALTER TABLE
"public"."habits"
ADD
CONSTRAINT "habits_id_key" UNIQUE USING INDEX "habits_id_key";
ALTER TABLE
"public"."habits"
ADD
CONSTRAINT "public_habits_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE NOT valid;
ALTER TABLE
"public"."habits" validate CONSTRAINT "public_habits_user_id_fkey";
ALTER TABLE
"public"."habits_progresses"
ADD
CONSTRAINT "habits_progresses_id_key" UNIQUE USING INDEX "habits_progresses_id_key";
ALTER TABLE
"public"."habits_progresses"
ADD
CONSTRAINT "public_habits_progresses_habit_id_fkey" FOREIGN KEY (habit_id) REFERENCES habits(id) ON UPDATE CASCADE ON DELETE CASCADE NOT valid;
ALTER TABLE
"public"."habits_progresses" validate CONSTRAINT "public_habits_progresses_habit_id_fkey";
GRANT DELETE ON TABLE "public"."habits" TO "anon";
GRANT
INSERT
ON TABLE "public"."habits" TO "anon";
GRANT REFERENCES ON TABLE "public"."habits" TO "anon";
GRANT
SELECT
ON TABLE "public"."habits" TO "anon";
GRANT trigger ON TABLE "public"."habits" TO "anon";
GRANT TRUNCATE ON TABLE "public"."habits" TO "anon";
GRANT
UPDATE
ON TABLE "public"."habits" TO "anon";
GRANT DELETE ON TABLE "public"."habits" TO "authenticated";
GRANT
INSERT
ON TABLE "public"."habits" TO "authenticated";
GRANT REFERENCES ON TABLE "public"."habits" TO "authenticated";
GRANT
SELECT
ON TABLE "public"."habits" TO "authenticated";
GRANT trigger ON TABLE "public"."habits" TO "authenticated";
GRANT TRUNCATE ON TABLE "public"."habits" TO "authenticated";
GRANT
UPDATE
ON TABLE "public"."habits" TO "authenticated";
GRANT DELETE ON TABLE "public"."habits" TO "service_role";
GRANT
INSERT
ON TABLE "public"."habits" TO "service_role";
GRANT REFERENCES ON TABLE "public"."habits" TO "service_role";
GRANT
SELECT
ON TABLE "public"."habits" TO "service_role";
GRANT trigger ON TABLE "public"."habits" TO "service_role";
GRANT TRUNCATE ON TABLE "public"."habits" TO "service_role";
GRANT
UPDATE
ON TABLE "public"."habits" TO "service_role";
GRANT DELETE ON TABLE "public"."habits_progresses" TO "anon";
GRANT
INSERT
ON TABLE "public"."habits_progresses" TO "anon";
GRANT REFERENCES ON TABLE "public"."habits_progresses" TO "anon";
GRANT
SELECT
ON TABLE "public"."habits_progresses" TO "anon";
GRANT trigger ON TABLE "public"."habits_progresses" TO "anon";
GRANT TRUNCATE ON TABLE "public"."habits_progresses" TO "anon";
GRANT
UPDATE
ON TABLE "public"."habits_progresses" TO "anon";
GRANT DELETE ON TABLE "public"."habits_progresses" TO "authenticated";
GRANT
INSERT
ON TABLE "public"."habits_progresses" TO "authenticated";
GRANT REFERENCES ON TABLE "public"."habits_progresses" TO "authenticated";
GRANT
SELECT
ON TABLE "public"."habits_progresses" TO "authenticated";
GRANT trigger ON TABLE "public"."habits_progresses" TO "authenticated";
GRANT TRUNCATE ON TABLE "public"."habits_progresses" TO "authenticated";
GRANT
UPDATE
ON TABLE "public"."habits_progresses" TO "authenticated";
GRANT DELETE ON TABLE "public"."habits_progresses" TO "service_role";
GRANT
INSERT
ON TABLE "public"."habits_progresses" TO "service_role";
GRANT REFERENCES ON TABLE "public"."habits_progresses" TO "service_role";
GRANT
SELECT
ON TABLE "public"."habits_progresses" TO "service_role";
GRANT trigger ON TABLE "public"."habits_progresses" TO "service_role";
GRANT TRUNCATE ON TABLE "public"."habits_progresses" TO "service_role";
GRANT
UPDATE
ON TABLE "public"."habits_progresses" TO "service_role";
CREATE policy "Enable delete for users based on user_id" ON "public"."habits" AS permissive FOR DELETE TO public USING ((auth.uid() = user_id));
CREATE policy "Enable insert for users based on user_id" ON "public"."habits" AS permissive FOR
INSERT
TO public WITH CHECK ((auth.uid() = user_id));
CREATE policy "Enable select for users based on user_id" ON "public"."habits" AS permissive FOR
SELECT
TO public USING ((auth.uid() = user_id));
CREATE policy "Enable update for users based on user_id" ON "public"."habits" AS permissive FOR
UPDATE
TO public USING ((auth.uid() = user_id)) WITH CHECK ((auth.uid() = user_id));
CREATE policy "Enable delete for users based on user_id" ON "public"."habits_progresses" AS permissive FOR DELETE TO public USING (
(
EXISTS (
SELECT
1
FROM
(
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
WHERE
(
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
)
)
);
CREATE policy "Enable insert for users based on user_id" ON "public"."habits_progresses" AS permissive FOR
INSERT
TO public WITH CHECK (
(
EXISTS (
SELECT
1
FROM
(
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
WHERE
(
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
)
)
);
CREATE policy "Enable select for users based on user_id" ON "public"."habits_progresses" AS permissive FOR
SELECT
TO public USING (
(
EXISTS (
SELECT
1
FROM
(
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
WHERE
(
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
)
)
);
CREATE policy "Enable update for users based on user_id" ON "public"."habits_progresses" AS permissive FOR
UPDATE
TO public USING (
(
EXISTS (
SELECT
1
FROM
(
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
WHERE
(
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
)
)
) WITH CHECK (
(
EXISTS (
SELECT
1
FROM
(
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
WHERE
(
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
)
)
);

View File

@ -0,0 +1,264 @@
-- Users
-- User { email: 'test@test.com', password: 'test1234' }
INSERT INTO
"auth"."users" (
"instance_id",
"id",
"aud",
"role",
"email",
"encrypted_password",
"email_confirmed_at",
"invited_at",
"confirmation_token",
"confirmation_sent_at",
"recovery_token",
"recovery_sent_at",
"email_change_token_new",
"email_change",
"email_change_sent_at",
"last_sign_in_at",
"raw_app_meta_data",
"raw_user_meta_data",
"is_super_admin",
"created_at",
"updated_at",
"phone",
"phone_confirmed_at",
"phone_change",
"phone_change_token",
"phone_change_sent_at",
"email_change_token_current",
"email_change_confirm_status",
"banned_until",
"reauthentication_token",
"reauthentication_sent_at",
"is_sso_user",
"deleted_at"
)
VALUES
(
'00000000-0000-0000-0000-000000000000',
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'authenticated',
'authenticated',
'test@test.com',
crypt('test1234', gen_salt('bf')),
timezone('utc' :: text, NOW()),
NULL,
'',
NULL,
'',
NULL,
'',
'',
NULL,
NULL,
'{"provider": "email", "providers": ["email"]}',
'{}',
NULL,
timezone('utc' :: text, NOW()),
timezone('utc' :: text, NOW()),
NULL,
NULL,
'',
'',
NULL,
'',
0,
NULL,
'',
NULL,
false,
NULL
);
INSERT INTO
"auth"."identities" (
"id",
"user_id",
"identity_data",
"provider",
"provider_id",
"last_sign_in_at",
"created_at",
"updated_at"
)
VALUES
(
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'{"sub": "ab054ee9-fbb4-473e-942b-bbf4415f4bef", "email": "test@test.com", "display_name": "Test"}',
'email',
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
timezone('utc' :: text, NOW()),
timezone('utc' :: text, NOW()),
timezone('utc' :: text, NOW())
);
-- Habits
INSERT INTO
"public"."habits" (
id,
user_id,
name,
color,
icon,
start_date,
end_date,
goal_frequency,
goal_target,
goal_target_unit
)
VALUES
(
1,
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'Wake up at 07h00',
'#006CFF',
'bed',
timezone('utc' :: text, NOW()),
NULL,
'daily',
NULL,
NULL
);
INSERT INTO
"public"."habits" (
id,
user_id,
name,
color,
icon,
start_date,
end_date,
goal_frequency,
goal_target,
goal_target_unit
)
VALUES
(
2,
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'Learn English',
'#EB4034',
'language',
timezone('utc' :: text, NOW()),
NULL,
'daily',
30,
'minutes'
);
INSERT INTO
"public"."habits" (
id,
user_id,
name,
color,
icon,
start_date,
end_date,
goal_frequency,
goal_target,
goal_target_unit
)
VALUES
(
3,
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'Walk',
'#228B22',
'person-walking',
timezone('utc' :: text, NOW()),
NULL,
'daily',
5000,
'steps'
);
INSERT INTO
"public"."habits" (
id,
user_id,
name,
color,
icon,
start_date,
end_date,
goal_frequency,
goal_target,
goal_target_unit
)
VALUES
(
4,
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'Clean the house',
'#808080',
'broom',
timezone('utc' :: text, NOW()),
NULL,
'weekly',
NULL,
NULL
);
INSERT INTO
"public"."habits" (
id,
user_id,
name,
color,
icon,
start_date,
end_date,
goal_frequency,
goal_target,
goal_target_unit
)
VALUES
(
5,
'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'Solve Programming Challenges',
'#DE3163',
'code',
timezone('utc' :: text, NOW()),
NULL,
'monthly',
5,
'challenges'
);
-- Habits Progresses
INSERT INTO
"public"."habits_progresses" (
id,
habit_id,
date,
goal_progress
)
VALUES
(
1,
4,
timezone('utc' :: text, NOW()),
1
);
INSERT INTO
"public"."habits_progresses" (
id,
habit_id,
date,
goal_progress
)
VALUES
(
2,
3,
timezone('utc' :: text, NOW()),
4733
);

View File

@ -0,0 +1,388 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export interface Database {
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
operationName?: string
query?: string
variables?: Json
extensions?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
habits: {
Row: {
color: string
end_date: string | null
goal_frequency: Database["public"]["Enums"]["goal_frequency"]
goal_target: number | null
goal_target_unit: string | null
icon: string
id: number
name: string
start_date: string
user_id: string
}
Insert: {
color: string
end_date?: string | null
goal_frequency: Database["public"]["Enums"]["goal_frequency"]
goal_target?: number | null
goal_target_unit?: string | null
icon: string
id?: number
name: string
start_date?: string
user_id?: string
}
Update: {
color?: string
end_date?: string | null
goal_frequency?: Database["public"]["Enums"]["goal_frequency"]
goal_target?: number | null
goal_target_unit?: string | null
icon?: string
id?: number
name?: string
start_date?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "public_habits_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
habits_progresses: {
Row: {
date: string
goal_progress: number
habit_id: number
id: number
}
Insert: {
date?: string
goal_progress: number
habit_id: number
id?: number
}
Update: {
date?: string
goal_progress?: number
habit_id?: number
id?: number
}
Relationships: [
{
foreignKeyName: "public_habits_progresses_habit_id_fkey"
columns: ["habit_id"]
isOneToOne: false
referencedRelation: "habits"
referencedColumns: ["id"]
},
]
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
goal_frequency: "daily" | "weekly" | "monthly"
}
CompositeTypes: {
[_ in never]: never
}
}
storage: {
Tables: {
buckets: {
Row: {
allowed_mime_types: string[] | null
avif_autodetection: boolean | null
created_at: string | null
file_size_limit: number | null
id: string
name: string
owner: string | null
owner_id: string | null
public: boolean | null
updated_at: string | null
}
Insert: {
allowed_mime_types?: string[] | null
avif_autodetection?: boolean | null
created_at?: string | null
file_size_limit?: number | null
id: string
name: string
owner?: string | null
owner_id?: string | null
public?: boolean | null
updated_at?: string | null
}
Update: {
allowed_mime_types?: string[] | null
avif_autodetection?: boolean | null
created_at?: string | null
file_size_limit?: number | null
id?: string
name?: string
owner?: string | null
owner_id?: string | null
public?: boolean | null
updated_at?: string | null
}
Relationships: []
}
migrations: {
Row: {
executed_at: string | null
hash: string
id: number
name: string
}
Insert: {
executed_at?: string | null
hash: string
id: number
name: string
}
Update: {
executed_at?: string | null
hash?: string
id?: number
name?: string
}
Relationships: []
}
objects: {
Row: {
bucket_id: string | null
created_at: string | null
id: string
last_accessed_at: string | null
metadata: Json | null
name: string | null
owner: string | null
owner_id: string | null
path_tokens: string[] | null
updated_at: string | null
version: string | null
}
Insert: {
bucket_id?: string | null
created_at?: string | null
id?: string
last_accessed_at?: string | null
metadata?: Json | null
name?: string | null
owner?: string | null
owner_id?: string | null
path_tokens?: string[] | null
updated_at?: string | null
version?: string | null
}
Update: {
bucket_id?: string | null
created_at?: string | null
id?: string
last_accessed_at?: string | null
metadata?: Json | null
name?: string | null
owner?: string | null
owner_id?: string | null
path_tokens?: string[] | null
updated_at?: string | null
version?: string | null
}
Relationships: [
{
foreignKeyName: "objects_bucketId_fkey"
columns: ["bucket_id"]
isOneToOne: false
referencedRelation: "buckets"
referencedColumns: ["id"]
},
]
}
}
Views: {
[_ in never]: never
}
Functions: {
can_insert_object: {
Args: {
bucketid: string
name: string
owner: string
metadata: Json
}
Returns: undefined
}
extension: {
Args: {
name: string
}
Returns: string
}
filename: {
Args: {
name: string
}
Returns: string
}
foldername: {
Args: {
name: string
}
Returns: unknown
}
get_size_by_bucket: {
Args: Record<PropertyKey, never>
Returns: Array<{
size: number
bucket_id: string
}>
}
search: {
Args: {
prefix: string
bucketname: string
limits?: number
levels?: number
offsets?: number
search?: string
sortcolumn?: string
sortorder?: string
}
Returns: Array<{
name: string
id: string
updated_at: string
created_at: string
last_accessed_at: string
metadata: Json
}>
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type PublicSchema = Database[Extract<keyof Database, "public">]
export type Tables<
PublicTableNameOrOptions extends
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
PublicSchema["Views"])
? (PublicSchema["Tables"] &
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
PublicEnumNameOrOptions extends
| keyof PublicSchema["Enums"]
| { schema: keyof Database },
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = PublicEnumNameOrOptions extends { schema: keyof Database }
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
? PublicSchema["Enums"][PublicEnumNameOrOptions]
: never

View File

@ -0,0 +1,11 @@
import { createClient } from "@supabase/supabase-js"
import type { Database } from "./supabase-types"
const SUPABASE_URL = process.env["EXPO_PUBLIC_SUPABASE_URL"] ?? ""
const SUPABASE_ANON_KEY = process.env["EXPO_PUBLIC_SUPABASE_ANON_KEY"] ?? ""
export const supabaseClient = createClient<Database>(
SUPABASE_URL,
SUPABASE_ANON_KEY,
)

1
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1 @@
# Clean Architecture

36
docs/CONVENTIONS.md Normal file
View File

@ -0,0 +1,36 @@
# Conventions développement informatique
## Linting/Formatting/Tests
Le code est formaté grâce à [Prettier](https://prettier.io/) et vérifié grâce à [ESLint](https://eslint.org/) et à [TypeScript](https://www.typescriptlang.org/) pour s'assurer que le code respecte les bonnes pratiques de développement, et détecter en amont les possibles erreurs.
Nous utilisons également [Jest](https://jestjs.io/) pour les tests automatisés.
```sh
# Lint
npm run lint:prettier
npm run lint:eslint
npm run lint:typescript
# Test
npm run test
```
Une pipeline CI ([`.gitlab-ci.yml`](.gitlab-ci.yml)) est en place pour vérifier que le code respecte ces bonnes pratiques et que les tests passent.
## GitFlow
Le projet suit la convention [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) reposant sur 2 branches principales:
- `main` (ou `master`): Contient le code de la dernière version stable et déployé en production.
- `develop`: Contient le code en cours de développement. Les nouvelles fonctionnalités et les correctifs de bugs sont fusionnés ici.
## Convention des commits
Les commits respectent la convention [Conventional Commits](https://www.conventionalcommits.org/) et [Semantic Versioning](https://semver.org/) pour la gestion des versions et des releases en fonction des commits.
Les commits doivent être **atomiques** c'est à dire qu'il respecte 3 règles:
- Ne concerne qu'un seul sujet (une fonctionnalité, une correction de bug, etc.)
- Doit avoir un message clair et concis
- Ne doit pas rendre de dépôt "incohérent" (bloque la CI du projet)

42
docs/MLD.md Normal file
View File

@ -0,0 +1,42 @@
# Modèle Logique des Données (MLD)
## Introduction
Le **Modèle Logique des Données (MLD)** est une représentation de la structure de la base de données de l'application.
On représente ainsi les données sous la forme suivante:
- Chaque table est représentée par un bloc.
- Le nom de la table est écrit en **gras**.
- Les champs sont listés en dessous du nom de la table.
- Les clés primaires sont <u>soulignées</u> et placées au début de la liste des champs.
- Les clés étrangères sont préfixées par un dièse (#), et placées après les clés primaires. Les clés étrangères sont
suivies entre parenthèses du nom de la table suivi d'une flèche (->) et du nom du champ de la table référencée.
## Modèle
- **users**
- <u>id</u>
- email (unique)
- display_name
- encrypted_password
- role
- email_confirmed_at (nullable)
- created_at
- updated_at
- **habits**
- <u>id</u>
- #user_id (Users->id)
- name
- color
- icon
- start_date
- end_date (nullable)
- goal_frequency (enum: `daily`, `weekly`, `monthly`)
- goal_target (nullable)
- goal_target_unit (nullable)
- **habits_progresses**
- <u>id</u>
- #habit_id (Habits->id)
- date
- goal_progress

View File

@ -0,0 +1,21 @@
import { useEffect, useState } from "react"
import type { Presenter } from "@/data/infrastructure/presenters/_Presenter"
export const usePresenterState = <S>(presenter: Presenter<S>): S => {
const [state, setState] = useState<S>(presenter.initialState)
useEffect(() => {
const presenterSubscription = (state: S): void => {
setState(state)
}
presenter.subscribe(presenterSubscription)
return () => {
return presenter.unsubscribe(presenterSubscription)
}
}, [presenter])
return state
}

4339
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,23 +14,26 @@
"lint:typescript": "tsc --noEmit", "lint:typescript": "tsc --noEmit",
"lint:staged": "lint-staged", "lint:staged": "lint-staged",
"test": "jest --coverage --reporters=default --reporters=jest-junit", "test": "jest --coverage --reporters=default --reporters=jest-junit",
"supabase": "supabase --workdir \"./data/infrastructure/repositories\"",
"postinstall": "husky" "postinstall": "husky"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "14.0.0", "@expo/vector-icons": "14.0.0",
"@react-native-async-storage/async-storage": "1.21.0", "@react-native-async-storage/async-storage": "1.21.0",
"@react-navigation/native": "6.1.10", "@react-navigation/native": "6.1.16",
"expo": "50.0.7", "@supabase/supabase-js": "2.39.8",
"expo": "50.0.13",
"expo-font": "11.10.3", "expo-font": "11.10.3",
"expo-linking": "6.2.2", "expo-linking": "6.2.2",
"expo-router": "3.4.7", "expo-router": "3.4.8",
"expo-splash-screen": "0.26.4", "expo-splash-screen": "0.26.4",
"expo-status-bar": "1.11.1", "expo-status-bar": "1.11.1",
"expo-system-ui": "2.9.3", "expo-system-ui": "2.9.3",
"expo-web-browser": "12.8.2", "expo-web-browser": "12.8.2",
"immer": "10.0.4",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.73.4", "react-native": "0.73.5",
"react-native-paper": "5.12.3", "react-native-paper": "5.12.3",
"react-native-safe-area-context": "4.8.2", "react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0", "react-native-screens": "3.29.0",
@ -38,33 +41,35 @@
"react-native-web": "0.19.10" "react-native-web": "0.19.10"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.23.9", "@babel/core": "7.24.0",
"@commitlint/cli": "18.6.1", "@commitlint/cli": "19.1.0",
"@commitlint/config-conventional": "18.6.2", "@commitlint/config-conventional": "19.1.0",
"@testing-library/react-native": "12.4.3", "@testing-library/react-native": "12.4.3",
"@total-typescript/ts-reset": "0.5.1", "@total-typescript/ts-reset": "0.5.1",
"@tsconfig/strictest": "2.0.3", "@tsconfig/strictest": "2.0.3",
"@types/jest": "29.5.12", "@types/jest": "29.5.12",
"@types/react": "18.2.55", "@types/node": "20.11.28",
"@types/react": "18.2.66",
"@types/react-test-renderer": "18.0.7", "@types/react-test-renderer": "18.0.7",
"@typescript-eslint/eslint-plugin": "7.0.1", "@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.0.1", "@typescript-eslint/parser": "7.2.0",
"eslint": "8.56.0", "eslint": "8.57.0",
"eslint-config-conventions": "14.1.0", "eslint-config-conventions": "14.1.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1", "eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "5.1.3", "eslint-plugin-prettier": "5.1.3",
"eslint-plugin-promise": "6.1.1", "eslint-plugin-promise": "6.1.1",
"eslint-plugin-react": "7.33.2", "eslint-plugin-react": "7.34.0",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-native": "4.1.0", "eslint-plugin-react-native": "4.1.0",
"eslint-plugin-unicorn": "51.0.1", "eslint-plugin-unicorn": "51.0.1",
"husky": "9.0.11", "husky": "9.0.11",
"jest": "29.7.0", "jest": "29.7.0",
"jest-expo": "50.0.2", "jest-expo": "50.0.4",
"jest-junit": "16.0.0", "jest-junit": "16.0.0",
"lint-staged": "15.2.2", "lint-staged": "15.2.2",
"react-test-renderer": "18.2.0", "react-test-renderer": "18.2.0",
"typescript": "5.3.3" "supabase": "1.148.6",
"typescript": "5.4.2"
} }
} }