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
.env*.local
.env
# typescript
*.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.
- [Sujet](./docs/SUJET.md)
- [Cahier des charges](./docs/CAHIER-DES-CHARGES.md)
### Membres du Groupe 7
- [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 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
### 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
- [npm](https://www.npmjs.com/) >= 10.0.0
- [Expo Go](https://expo.io/client)
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
### Installation
@ -30,6 +44,12 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
# Cloner le projet
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
npm clean-install
```
@ -40,37 +60,10 @@ npm clean-install
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.
Nous utilisons également [Jest](https://jestjs.io/) pour les tests automatisés.
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.
```sh
# Lint
npm run lint:prettier
npm run lint:eslint
npm run lint:typescript
# Test
npm run test
npm run supabase
```
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",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"scheme": "p61-project",
"userInterfaceStyle": "automatic",
"splash": {
"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 { Button } from "react-native-paper"
import { StyleSheet, Text, View } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import { useHabitsTracker } from "@/contexts/HabitsTracker"
const HomePage: React.FC = () => {
const { habitsTrackerPresenterState } = useHabitsTracker()
const { habitsTracker } = habitsTrackerPresenterState
const { habitProgressHistories } = habitsTracker
return (
<SafeAreaView style={styles.container}>
<Button
mode="contained"
onPress={() => {
return console.log("Pressed")
}}
>
Press me
</Button>
{habitProgressHistories.map((progressHistory) => {
const { habit } = progressHistory
return (
<View key={habit.id}>
<Text>{habit.name}</Text>
</View>
)
})}
</SafeAreaView>
)
}

View File

@ -11,6 +11,7 @@ import { StatusBar } from "expo-status-bar"
import CanterburyFont from "../assets/fonts/Canterbury.ttf"
import GeoramFont from "../assets/fonts/Georama-Black.ttf"
import SpaceMonoFont from "../assets/fonts/SpaceMono-Regular.ttf"
import { HabitsTrackerProvider } from "@/contexts/HabitsTracker"
export { ErrorBoundary } from "expo-router"
@ -48,26 +49,28 @@ const RootLayout: React.FC = () => {
}
return (
<PaperProvider
theme={{
...DefaultTheme,
colors: {
...DefaultTheme.colors,
primary: "#f57c00",
secondary: "#fbc02d",
},
}}
>
<Stack
screenOptions={{
headerShown: false,
<HabitsTrackerProvider>
<PaperProvider
theme={{
...DefaultTheme,
colors: {
...DefaultTheme.colors,
primary: "#f57c00",
secondary: "#fbc02d",
},
}}
>
<Stack.Screen name="(pages)" />
</Stack>
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(pages)" />
</Stack>
<StatusBar style="dark" />
</PaperProvider>
<StatusBar style="dark" />
</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:staged": "lint-staged",
"test": "jest --coverage --reporters=default --reporters=jest-junit",
"supabase": "supabase --workdir \"./data/infrastructure/repositories\"",
"postinstall": "husky"
},
"dependencies": {
"@expo/vector-icons": "14.0.0",
"@react-native-async-storage/async-storage": "1.21.0",
"@react-navigation/native": "6.1.10",
"expo": "50.0.7",
"@react-navigation/native": "6.1.16",
"@supabase/supabase-js": "2.39.8",
"expo": "50.0.13",
"expo-font": "11.10.3",
"expo-linking": "6.2.2",
"expo-router": "3.4.7",
"expo-router": "3.4.8",
"expo-splash-screen": "0.26.4",
"expo-status-bar": "1.11.1",
"expo-system-ui": "2.9.3",
"expo-web-browser": "12.8.2",
"immer": "10.0.4",
"react": "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-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0",
@ -38,33 +41,35 @@
"react-native-web": "0.19.10"
},
"devDependencies": {
"@babel/core": "7.23.9",
"@commitlint/cli": "18.6.1",
"@commitlint/config-conventional": "18.6.2",
"@babel/core": "7.24.0",
"@commitlint/cli": "19.1.0",
"@commitlint/config-conventional": "19.1.0",
"@testing-library/react-native": "12.4.3",
"@total-typescript/ts-reset": "0.5.1",
"@tsconfig/strictest": "2.0.3",
"@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",
"@typescript-eslint/eslint-plugin": "7.0.1",
"@typescript-eslint/parser": "7.0.1",
"eslint": "8.56.0",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"eslint": "8.57.0",
"eslint-config-conventions": "14.1.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "5.1.3",
"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-native": "4.1.0",
"eslint-plugin-unicorn": "51.0.1",
"husky": "9.0.11",
"jest": "29.7.0",
"jest-expo": "50.0.2",
"jest-expo": "50.0.4",
"jest-junit": "16.0.0",
"lint-staged": "15.2.2",
"react-test-renderer": "18.2.0",
"typescript": "5.3.3"
"supabase": "1.148.6",
"typescript": "5.4.2"
}
}