mirror of
https://github.com/morten-olsen/bob-the-algorithm.git
synced 2026-02-08 00:46:25 +01:00
monorepo
This commit is contained in:
6
packages/app/.expo-shared/assets.json
Normal file
6
packages/app/.expo-shared/assets.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true,
|
||||
"af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true,
|
||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||
}
|
||||
15
packages/app/.gitignore
vendored
Normal file
15
packages/app/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/*.log
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
51
packages/app/app.config.js
Normal file
51
packages/app/app.config.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const pkg = require('./package.json');
|
||||
const config = {
|
||||
expo: {
|
||||
name: 'Bob',
|
||||
slug: 'bob',
|
||||
version: pkg.version,
|
||||
orientation: 'portrait',
|
||||
icon: './assets/images/icon.png',
|
||||
scheme: 'bobthealgorithm',
|
||||
userInterfaceStyle: 'automatic',
|
||||
splash: {
|
||||
image: './assets/images/splash.png',
|
||||
resizeMode: 'contain',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
updates: {
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
assetBundlePatterns: ['**/*'],
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: 'pro.mortenolsen.bob',
|
||||
buildNumber: pkg.version,
|
||||
config: {
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/images/adaptive-icon.png',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
package: 'pro.mortenolsen.bob',
|
||||
},
|
||||
web: {
|
||||
favicon: './assets/images/favicon.png',
|
||||
},
|
||||
// hooks: {
|
||||
// postPublish: [
|
||||
// {
|
||||
// file: 'sentry-expo/upload-sourcemaps',
|
||||
// config: {
|
||||
// setCommits: true,
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
BIN
packages/app/assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
packages/app/assets/fonts/SpaceMono-Regular.ttf
Executable file
Binary file not shown.
BIN
packages/app/assets/images/adaptive-icon.png
Normal file
BIN
packages/app/assets/images/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
packages/app/assets/images/favicon.png
Normal file
BIN
packages/app/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/app/assets/images/icon.png
Normal file
BIN
packages/app/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
packages/app/assets/images/splash.png
Normal file
BIN
packages/app/assets/images/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
14
packages/app/babel.config.js
Normal file
14
packages/app/babel.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = function(api) {
|
||||
api.cache.using(() => process.env.NODE_ENV);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
[require.resolve('babel-plugin-module-resolver'), {
|
||||
alias: {
|
||||
'#': './src',
|
||||
'@morten-olsen/ui': '@morten-olsen/ui/src',
|
||||
},
|
||||
}],
|
||||
],
|
||||
};
|
||||
};
|
||||
25
packages/app/eas.json
Normal file
25
packages/app/eas.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 0.42.4"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "morten@olsen.pro",
|
||||
"appleTeamId": "D2944KU2BE",
|
||||
"ascAppId": "1623552387"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/app/metro.config.js
Normal file
20
packages/app/metro.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
// Find the workspace root, this can be replaced with `find-yarn-workspace-root`
|
||||
const workspaceRoot = path.resolve(__dirname, '../..');
|
||||
const projectRoot = __dirname;
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// 1. Watch all files within the monorepo
|
||||
config.watchFolders = [workspaceRoot];
|
||||
// 2. Let Metro know where to resolve packages, and in what order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
module.exports = config;
|
||||
|
||||
79
packages/app/package.json
Normal file
79
packages/app/package.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "@morten-olsen/bob",
|
||||
"version": "1.0.0",
|
||||
"main": "./src/index.ts",
|
||||
"homepage": "/bob-the-algorithm",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"eject": "expo eject",
|
||||
"test": "jest --watchAll",
|
||||
"build:web": "expo build:web"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "~17.0.21",
|
||||
"@types/react-dom": "~18.0.3",
|
||||
"react-error-overlay": "6.0.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^12.0.0",
|
||||
"@morten-olsen/ui": "workspace:^",
|
||||
"@react-native-async-storage/async-storage": "~1.15.0",
|
||||
"@react-navigation/bottom-tabs": "^6.0.5",
|
||||
"@react-navigation/native": "^6.0.2",
|
||||
"@react-navigation/native-stack": "^6.1.0",
|
||||
"@react-navigation/stack": "^6.2.1",
|
||||
"chroma-js": "^2.4.2",
|
||||
"date-fns": "^2.28.0",
|
||||
"expo": "~44.0.0",
|
||||
"expo-asset": "~8.4.4",
|
||||
"expo-calendar": "~10.1.0",
|
||||
"expo-constants": "~13.0.0",
|
||||
"expo-font": "~10.0.4",
|
||||
"expo-linking": "~3.0.0",
|
||||
"expo-location": "~14.0.1",
|
||||
"expo-random": "^12.1.2",
|
||||
"expo-splash-screen": "~0.14.0",
|
||||
"expo-status-bar": "~1.2.0",
|
||||
"expo-task-manager": "~10.1.0",
|
||||
"expo-updates": "~0.11.7",
|
||||
"expo-web-browser": "~10.1.0",
|
||||
"parse-css-color": "^0.2.1",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"react-native": "0.64.3",
|
||||
"react-native-calendar-strip": "^2.2.5",
|
||||
"react-native-calendars": "^1.1284.0",
|
||||
"react-native-collapsible": "^1.6.0",
|
||||
"react-native-gesture-handler": "^2.4.2",
|
||||
"react-native-get-random-values": "^1.8.0",
|
||||
"react-native-safe-area-context": "3.3.2",
|
||||
"react-native-screens": "~3.10.1",
|
||||
"react-native-web": "0.17.1",
|
||||
"string-to-color": "^2.2.2",
|
||||
"styled-components": "^5.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.5",
|
||||
"@types/chroma-js": "^2.1.3",
|
||||
"@types/react": "~17.0.21",
|
||||
"@types/react-dom": "^18.0.3",
|
||||
"@types/styled-components-react-native": "^5.1.3",
|
||||
"babel-loader": "^8.2.5",
|
||||
"babel-plugin-module-resolver": "^4.1.0",
|
||||
"expo-cli": "^5.4.3",
|
||||
"jest": "^26.6.3",
|
||||
"jest-expo": "~44.0.1",
|
||||
"react-refresh": "^0.13.0",
|
||||
"react-test-renderer": "17.0.1",
|
||||
"typescript": "~4.3.5",
|
||||
"webpack-hot-middleware": "^2.25.1"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
31
packages/app/src/app.tsx
Normal file
31
packages/app/src/app.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'react-native-get-random-values';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { useCallback } from 'react';
|
||||
import { Setup } from './features/setup';
|
||||
import { Router } from './ui/router';
|
||||
import { Provider } from '@morten-olsen/ui';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const getTransit = useCallback(
|
||||
async (from: any, to: any) => ({
|
||||
to,
|
||||
from,
|
||||
time: 45,
|
||||
usableTime: 0,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar />
|
||||
<Provider>
|
||||
<Setup getTransit={getTransit}>
|
||||
<Router />
|
||||
</Setup>
|
||||
</Provider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export { App };
|
||||
35
packages/app/src/features/appointments/context.ts
Normal file
35
packages/app/src/features/appointments/context.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createContext } from "react"
|
||||
import { Appointment } from "../data"
|
||||
import { Day } from "../day"
|
||||
|
||||
enum AppointmentsStatus {
|
||||
unavailable = 'unavailable',
|
||||
unapproved = 'unapproved',
|
||||
rejected = 'rejected',
|
||||
approved = 'approved',
|
||||
}
|
||||
|
||||
type AppointmentsContextUnavailable = {
|
||||
status: AppointmentsStatus.unavailable | AppointmentsStatus.rejected;
|
||||
}
|
||||
|
||||
type AppointmentsContextUnapprovedValue = {
|
||||
status: AppointmentsStatus.unapproved;
|
||||
request: () => Promise<void>;
|
||||
}
|
||||
|
||||
type AppointmentsContextApproved = {
|
||||
status: AppointmentsStatus.approved;
|
||||
getDay: (day: Day) => Promise<Appointment[]>
|
||||
}
|
||||
|
||||
type AppointmentsContextValue = AppointmentsContextUnavailable
|
||||
| AppointmentsContextUnapprovedValue
|
||||
| AppointmentsContextApproved;
|
||||
|
||||
const AppointmentsContext = createContext<AppointmentsContextValue>(undefined as any);
|
||||
|
||||
export type {
|
||||
AppointmentsContextValue,
|
||||
};
|
||||
export { AppointmentsContext, AppointmentsStatus };
|
||||
45
packages/app/src/features/appointments/hooks.ts
Normal file
45
packages/app/src/features/appointments/hooks.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useAsync, useAsyncCallback } from "#/features/async";
|
||||
import { useContext } from "react"
|
||||
import { Day, useDate } from "../day";
|
||||
import { AppointmentsContext, AppointmentsStatus } from "./context"
|
||||
|
||||
export const useAppointmentStatus = () => {
|
||||
const { status } = useContext(AppointmentsContext);
|
||||
return status;
|
||||
};
|
||||
|
||||
export const useAppointments = () => {
|
||||
const date = useDate();
|
||||
const context = useContext(AppointmentsContext);
|
||||
const result = useAsync(
|
||||
async () => {
|
||||
if (context.status !== AppointmentsStatus.approved) {
|
||||
return [];
|
||||
}
|
||||
const appointments = await context.getDay(date);
|
||||
return appointments;
|
||||
},
|
||||
[
|
||||
context.status === AppointmentsStatus.approved && context.getDay,
|
||||
date,
|
||||
],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useGetAppointments = () => {
|
||||
const context = useContext(AppointmentsContext);
|
||||
const result = useAsyncCallback(
|
||||
async (date: Day) => {
|
||||
if (context.status !== AppointmentsStatus.approved) {
|
||||
return [];
|
||||
}
|
||||
const appointments = await context.getDay(date);
|
||||
return appointments;
|
||||
},
|
||||
[
|
||||
context.status === AppointmentsStatus.approved && context.getDay,
|
||||
],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
2
packages/app/src/features/appointments/index.ts
Normal file
2
packages/app/src/features/appointments/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AppointmentsProvider } from './provider';
|
||||
export * from './hooks';
|
||||
35
packages/app/src/features/appointments/provider.tsx
Normal file
35
packages/app/src/features/appointments/provider.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useAsync } from "#/features/async";
|
||||
import { ReactNode } from "react"
|
||||
import { Platform } from "react-native";
|
||||
import { AppointmentsContext, AppointmentsContextValue, AppointmentsStatus } from './context';
|
||||
|
||||
type AppointmentsProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const AppointmentsProvider: React.FC<AppointmentsProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [value] = useAsync<AppointmentsContextValue>(
|
||||
async () => {
|
||||
if (Platform.OS !== 'ios') {
|
||||
return { status: AppointmentsStatus.unavailable };
|
||||
}
|
||||
return { status: AppointmentsStatus.unavailable };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (!value) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<AppointmentsContext.Provider value={value}>
|
||||
{children}
|
||||
</AppointmentsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type { AppointmentsProviderProps };
|
||||
export { AppointmentsProvider };
|
||||
97
packages/app/src/features/async/hooks.ts
Normal file
97
packages/app/src/features/async/hooks.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
type AsyncCallbackOutput<TArgs extends any[], TResult> = [
|
||||
(...args: TArgs) => Promise<TResult>,
|
||||
{
|
||||
loading: boolean;
|
||||
error?: any;
|
||||
result?: TResult;
|
||||
args?: TArgs;
|
||||
}
|
||||
];
|
||||
|
||||
type AsyncOutput<TResult> = [
|
||||
TResult | undefined,
|
||||
{
|
||||
loading: boolean;
|
||||
error?: any;
|
||||
rerun: () => Promise<TResult>;
|
||||
}
|
||||
]
|
||||
|
||||
const useAsyncCallback = <
|
||||
TArgs extends any[],
|
||||
TResult,
|
||||
>(fn: (...args: TArgs) => Promise<TResult>, deps: any[]): AsyncCallbackOutput<TArgs, TResult> => {
|
||||
const [result, setResult] = useState<TResult>();
|
||||
const [prevArgs, setPrevArgs] = useState<TArgs>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<any>();
|
||||
|
||||
const action = useCallback(fn, deps);
|
||||
|
||||
const invoke = useCallback(
|
||||
async (...args: TArgs) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setPrevArgs(args);
|
||||
try {
|
||||
const output = await action(...args);
|
||||
setResult(output);
|
||||
return output;
|
||||
} catch (err) {
|
||||
setResult(undefined);
|
||||
setError(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[setLoading, setError, setResult, action, ...deps],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => {
|
||||
const output: AsyncCallbackOutput<TArgs, TResult> = [
|
||||
invoke,
|
||||
{
|
||||
result,
|
||||
loading,
|
||||
error,
|
||||
args: prevArgs,
|
||||
}
|
||||
];
|
||||
return output;
|
||||
},
|
||||
[invoke, result, loading, error, prevArgs, ...deps],
|
||||
);
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const useAsync = <TResult>(fn: () => Promise<TResult>, deps: any[]): AsyncOutput<TResult> => {
|
||||
const [invoke, options] = useAsyncCallback(fn, deps);
|
||||
useEffect(
|
||||
() => {
|
||||
invoke();
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const localOptions = useMemo(
|
||||
() => ({
|
||||
loading: options.loading,
|
||||
error: options.error,
|
||||
rerun: invoke,
|
||||
}),
|
||||
[invoke, options.loading, options.error],
|
||||
);
|
||||
|
||||
return [
|
||||
options.result,
|
||||
localOptions,
|
||||
]
|
||||
};
|
||||
|
||||
export type { AsyncCallbackOutput };
|
||||
export { useAsync, useAsyncCallback };
|
||||
1
packages/app/src/features/async/index.ts
Normal file
1
packages/app/src/features/async/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './hooks';
|
||||
2
packages/app/src/features/data/index.ts
Normal file
2
packages/app/src/features/data/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export { timeUtils } from './utils';
|
||||
55
packages/app/src/features/data/types.ts
Normal file
55
packages/app/src/features/data/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Day } from "../day"
|
||||
|
||||
export enum TaskType {
|
||||
appointment = 'appointment',
|
||||
goal = 'goal',
|
||||
routine = 'routine',
|
||||
}
|
||||
|
||||
export type Time = {
|
||||
hour: number;
|
||||
minute: number;
|
||||
}
|
||||
|
||||
export type UserLocation = {
|
||||
id: string;
|
||||
title: string;
|
||||
position: {
|
||||
longitute: number;
|
||||
latitude: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type TaskBase = {
|
||||
type: TaskType;
|
||||
id: string;
|
||||
title: string;
|
||||
locations?: UserLocation[];
|
||||
required: boolean;
|
||||
priority?: number;
|
||||
startTime: {
|
||||
min: Time;
|
||||
max: Time;
|
||||
};
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export type Appointment = TaskBase & {
|
||||
type: TaskType.appointment;
|
||||
calendarId: string;
|
||||
}
|
||||
|
||||
export type Goal = TaskBase & {
|
||||
type: TaskType.goal;
|
||||
completed: boolean;
|
||||
deadline?: Day;
|
||||
startDate?: Day;
|
||||
days: boolean[];
|
||||
}
|
||||
|
||||
export type Routine = TaskBase & {
|
||||
type: TaskType.routine;
|
||||
days: boolean[];
|
||||
}
|
||||
|
||||
export type Task = Appointment | Goal | Routine;
|
||||
67
packages/app/src/features/data/utils.ts
Normal file
67
packages/app/src/features/data/utils.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Time } from "./types";
|
||||
|
||||
const equal = (a: Time, b: Time) => {
|
||||
return a.hour == b.hour && a.minute === b.minute;
|
||||
}
|
||||
|
||||
const stringToTime = (input: string) => {
|
||||
const [hourPart, minutePart] = input.split(':').map(a => a.trim()).filter(Boolean);
|
||||
const hour = parseInt(hourPart);
|
||||
const minute = parseInt(minutePart || '0');
|
||||
|
||||
if (
|
||||
!Number.isInteger(hour)
|
||||
|| !Number.isInteger(minute)
|
||||
|| Number.isNaN(hour)
|
||||
|| Number.isNaN(minute)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Time = {
|
||||
hour,
|
||||
minute,
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const largerThan = (a: Time, b: Time) => {
|
||||
return timeToMinutes(a) > timeToMinutes(b);
|
||||
}
|
||||
|
||||
const max = (a: Time, b: Time) => largerThan(a, b) ? a : b;
|
||||
const min = (a: Time, b: Time) => largerThan(a, b) ? b : a;
|
||||
|
||||
const timeToString = (input: Time) => `${input.hour}:${input.minute}`;
|
||||
|
||||
const timeToMinutes = (time: Time) => time.hour * 60 + time.minute;
|
||||
|
||||
const minutesToTime = (minutes: number): Time => {
|
||||
const hour = Math.floor(minutes / 60);
|
||||
const minute = minutes % 60;
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
const timeToDate = (time: Time) => {
|
||||
return new Date(0, 0, 0, time.hour, time.minute);
|
||||
}
|
||||
|
||||
const add = (a: Time, b: Time | number) => {
|
||||
const toAdd = typeof b === 'number' ? b : b.hour * 60 + b.minute
|
||||
const current = a.hour * 60 + a.minute + toAdd;
|
||||
return minutesToTime(current);
|
||||
}
|
||||
|
||||
const timeUtils = {
|
||||
timeToString,
|
||||
stringToTime,
|
||||
equal,
|
||||
largerThan,
|
||||
timeToMinutes,
|
||||
timeToDate,
|
||||
max,
|
||||
add,
|
||||
};
|
||||
|
||||
export { timeUtils };
|
||||
12
packages/app/src/features/day/context.ts
Normal file
12
packages/app/src/features/day/context.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createContext } from "react";
|
||||
import { Day } from ".";
|
||||
|
||||
type DateContextValue = {
|
||||
date: Day;
|
||||
setDate: (date: Day) => void;
|
||||
}
|
||||
|
||||
const DateContext = createContext<DateContextValue>(undefined as any);
|
||||
|
||||
export type { DateContextValue };
|
||||
export { DateContext }
|
||||
7
packages/app/src/features/day/day.ts
Normal file
7
packages/app/src/features/day/day.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
type Day = {
|
||||
year: number;
|
||||
month: number;
|
||||
date: number;
|
||||
}
|
||||
|
||||
export type { Day };
|
||||
12
packages/app/src/features/day/hooks.ts
Normal file
12
packages/app/src/features/day/hooks.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useContext } from "react"
|
||||
import { DateContext } from "./context"
|
||||
|
||||
export const useDate = () => {
|
||||
const { date } = useContext(DateContext);
|
||||
return date;
|
||||
}
|
||||
|
||||
export const useSetDate = () => {
|
||||
const { setDate } = useContext(DateContext);
|
||||
return setDate;
|
||||
}
|
||||
4
packages/app/src/features/day/index.ts
Normal file
4
packages/app/src/features/day/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DateProvider } from './provider';
|
||||
export type { Day } from './day';
|
||||
export * from './hooks';
|
||||
export { dayUtils } from './utils';
|
||||
20
packages/app/src/features/day/provider.tsx
Normal file
20
packages/app/src/features/day/provider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ReactNode, useState } from "react";
|
||||
import { DateContext } from "./context";
|
||||
import { dayUtils } from "./utils";
|
||||
|
||||
type DateProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const DateProvider: React.FC<DateProviderProps> = ({ children }) => {
|
||||
const [date, setDate] = useState(dayUtils.today());
|
||||
|
||||
return (
|
||||
<DateContext.Provider value={{ date, setDate }}>
|
||||
{children}
|
||||
</DateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type { DateProviderProps };
|
||||
export { DateProvider };
|
||||
30
packages/app/src/features/day/utils.ts
Normal file
30
packages/app/src/features/day/utils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Day } from "./day";
|
||||
|
||||
const today = () => {
|
||||
return dateToDay(new Date());
|
||||
}
|
||||
|
||||
const dayToDate = (day: Day) => {
|
||||
return new Date(day.year, day.month - 1, day.date, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
const dateToDay = (input: Date) => {
|
||||
const year = input.getFullYear();
|
||||
const month = input.getMonth() + 1;
|
||||
const date = input.getDate();
|
||||
const day: Day = { year, month, date };
|
||||
return day;
|
||||
}
|
||||
|
||||
const toId = (day: Day) => {
|
||||
return `${day.year.toString().padStart(4, '0')}-${day.month.toString().padStart(2, '0')}-${day.date.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const dayUtils = {
|
||||
today,
|
||||
dateToDay,
|
||||
dayToDate,
|
||||
toId,
|
||||
};
|
||||
|
||||
export { dayUtils };
|
||||
11
packages/app/src/features/goals/context.ts
Normal file
11
packages/app/src/features/goals/context.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createDataContext } from "#/utils/data-context";
|
||||
import { Goal } from "../data";
|
||||
|
||||
const {
|
||||
Context: GoalsContext,
|
||||
Provider: GoalsProvider,
|
||||
}= createDataContext<{[id: string]: Goal}>({
|
||||
createDefault: () => ({}),
|
||||
})
|
||||
|
||||
export { GoalsContext, GoalsProvider };
|
||||
41
packages/app/src/features/goals/hooks.ts
Normal file
41
packages/app/src/features/goals/hooks.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useContext, useMemo } from "react"
|
||||
import { Goal } from "../data";
|
||||
import { GoalsContext } from "./context"
|
||||
|
||||
export const useGoals = () => {
|
||||
const { data } = useContext(GoalsContext);
|
||||
const current = useMemo(
|
||||
() => Object.values(data),
|
||||
[data],
|
||||
)
|
||||
return current;
|
||||
};
|
||||
|
||||
export const useSetGoals = () => {
|
||||
const { setData } = useContext(GoalsContext);
|
||||
const set = useCallback(
|
||||
(goal: Goal) => setData(current => ({
|
||||
...current,
|
||||
[goal.id]: goal,
|
||||
})),
|
||||
[setData],
|
||||
);
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
export const useRemoveGoal = () => {
|
||||
const { setData } = useContext(GoalsContext);
|
||||
const removeRoutine = useCallback(
|
||||
(id: string) => {
|
||||
setData(current => {
|
||||
const next = {...current};
|
||||
delete next[id];
|
||||
return next;
|
||||
})
|
||||
},
|
||||
[setData],
|
||||
);
|
||||
|
||||
return removeRoutine;
|
||||
}
|
||||
30
packages/app/src/features/location/context.ts
Normal file
30
packages/app/src/features/location/context.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Time, UserLocation } from "../data";
|
||||
import { createContext } from "react"
|
||||
|
||||
type Transition = {
|
||||
time: number;
|
||||
usableTime: number;
|
||||
to: UserLocation;
|
||||
from: UserLocation;
|
||||
};
|
||||
|
||||
type GetTransition = (
|
||||
from: UserLocation,
|
||||
to: UserLocation,
|
||||
time: Time,
|
||||
) => Promise<Transition>;
|
||||
|
||||
type LocationContextValue = {
|
||||
locations: {
|
||||
[id: string]: UserLocation;
|
||||
};
|
||||
set: (location: UserLocation) => any;
|
||||
remove: (id: string) => any;
|
||||
lookup?: (address: string) => UserLocation[];
|
||||
getTransition: GetTransition;
|
||||
}
|
||||
|
||||
const LocationContext = createContext<LocationContextValue>(undefined as any);
|
||||
|
||||
export type { LocationContextValue, GetTransition, Transition };
|
||||
export { LocationContext };
|
||||
74
packages/app/src/features/location/hooks.ts
Normal file
74
packages/app/src/features/location/hooks.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useAsync } from "#/features/async";
|
||||
import { useContext, useMemo } from "react"
|
||||
import { requestForegroundPermissionsAsync, getCurrentPositionAsync } from 'expo-location';
|
||||
import { LocationContext } from "./context"
|
||||
import { UserLocation } from "../data";
|
||||
import { getDistanceFromLatLonInKm } from "./utils";
|
||||
|
||||
export const useLocations = () => {
|
||||
const { locations } = useContext(LocationContext);
|
||||
const result = useMemo(() => Object.values(locations), [locations]);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useSetLocation = () => {
|
||||
const { set } = useContext(LocationContext);
|
||||
return set;
|
||||
}
|
||||
|
||||
export const useRemoveLocation = () => {
|
||||
const { remove } = useContext(LocationContext);
|
||||
return remove;
|
||||
}
|
||||
|
||||
export const useGetTransition = () => {
|
||||
const { getTransition } = useContext(LocationContext);
|
||||
return getTransition;
|
||||
}
|
||||
|
||||
export const useLookup = () => {
|
||||
const { lookup } = useContext(LocationContext);
|
||||
return lookup;
|
||||
}
|
||||
|
||||
export const useCurrentLocation = (proximity: number = 0.5) => {
|
||||
const { locations } = useContext(LocationContext);
|
||||
const result = useAsync<UserLocation | undefined>(
|
||||
async () => {
|
||||
let { status } = await requestForegroundPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
return undefined;
|
||||
}
|
||||
let position = await getCurrentPositionAsync({});
|
||||
const withDistance = Object.values(locations).map((location) => {
|
||||
if (!location.position) {
|
||||
return;
|
||||
}
|
||||
const distance = getDistanceFromLatLonInKm(
|
||||
position.coords.latitude,
|
||||
position.coords.longitude,
|
||||
location.position.latitude,
|
||||
location.position.longitute,
|
||||
)
|
||||
return {
|
||||
distance,
|
||||
location,
|
||||
}
|
||||
}).filter(Boolean).sort((a, b) => a!.distance - b!.distance)
|
||||
const current = withDistance.find(d => d!.distance < proximity);
|
||||
if (!current) {
|
||||
return {
|
||||
id: `${position.coords.longitude} ${position.coords.latitude}`,
|
||||
title: 'Unknown',
|
||||
position: {
|
||||
latitude: position.coords.latitude,
|
||||
longitute: position.coords.longitude,
|
||||
},
|
||||
};
|
||||
}
|
||||
return current.location;
|
||||
},
|
||||
[],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
3
packages/app/src/features/location/index.ts
Normal file
3
packages/app/src/features/location/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { Transition, GetTransition } from './context';
|
||||
export { LocationProvider } from './provider';
|
||||
export * from './hooks';
|
||||
73
packages/app/src/features/location/provider.tsx
Normal file
73
packages/app/src/features/location/provider.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useAsync, useAsyncCallback } from "#/features/async";
|
||||
import { GetTransition } from "./context";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { LocationContext } from "./context";
|
||||
import { UserLocation } from "../data";
|
||||
|
||||
type LocationProviderProps = {
|
||||
children: ReactNode;
|
||||
lookup: (address: string) => UserLocation[];
|
||||
getTransition: GetTransition;
|
||||
}
|
||||
|
||||
const LOCATION_STORAGE_KEY = 'locations';
|
||||
|
||||
const LocationProvider: React.FC<LocationProviderProps> = ({
|
||||
children,
|
||||
lookup,
|
||||
getTransition,
|
||||
}) => {
|
||||
const [locations, setLocations] = useState<{[id: string]: UserLocation}>({});
|
||||
|
||||
useAsync(
|
||||
async () => {
|
||||
const raw = await AsyncStorage.getItem(LOCATION_STORAGE_KEY);
|
||||
if (raw) {
|
||||
setLocations(JSON.parse(raw));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [set] = useAsyncCallback(
|
||||
async (location: UserLocation) => {
|
||||
const index = {
|
||||
...locations,
|
||||
[location.id]: location,
|
||||
}
|
||||
setLocations(index);
|
||||
await AsyncStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(index));
|
||||
},
|
||||
[setLocations, locations],
|
||||
)
|
||||
|
||||
const [remove] = useAsyncCallback(
|
||||
async (id: string) => {
|
||||
const index = {
|
||||
...locations,
|
||||
}
|
||||
delete index[id];
|
||||
setLocations(index);
|
||||
await AsyncStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(index));
|
||||
},
|
||||
[setLocations, locations],
|
||||
);
|
||||
|
||||
return (
|
||||
<LocationContext.Provider
|
||||
value={{
|
||||
locations,
|
||||
set,
|
||||
remove,
|
||||
lookup,
|
||||
getTransition,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LocationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type { LocationProviderProps };
|
||||
export { LocationProvider };
|
||||
17
packages/app/src/features/location/utils.ts
Normal file
17
packages/app/src/features/location/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function getDistanceFromLatLonInKm(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||
var R = 6371; // Radius of the earth in km
|
||||
var dLat = deg2rad(lat2-lat1); // deg2rad below
|
||||
var dLon = deg2rad(lon2-lon1);
|
||||
var a =
|
||||
Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2)
|
||||
;
|
||||
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
var d = R * c; // Distance in km
|
||||
return d;
|
||||
}
|
||||
|
||||
function deg2rad(deg: number) {
|
||||
return deg * (Math.PI/180)
|
||||
}
|
||||
30
packages/app/src/features/overrides/context.ts
Normal file
30
packages/app/src/features/overrides/context.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createContext, SetStateAction } from "react";
|
||||
import { Time, UserLocation } from "../data";
|
||||
import { Day } from "../day";
|
||||
|
||||
type Override = {
|
||||
locations?: UserLocation[] | null;
|
||||
startMin?: Time;
|
||||
startMax?: Time;
|
||||
duration?: number;
|
||||
required?: boolean;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
type OverrideIndex = {
|
||||
startTime?: Time;
|
||||
tasks: {
|
||||
[id: string]: Override;
|
||||
};
|
||||
};
|
||||
|
||||
type OverrideContextValue = {
|
||||
overrides: OverrideIndex;
|
||||
get: (date: Day) => Promise<OverrideIndex>;
|
||||
set: React.Dispatch<SetStateAction<OverrideIndex>>;
|
||||
}
|
||||
const OverrideContext = createContext<OverrideContextValue>(undefined as any);
|
||||
|
||||
export type { Override, OverrideIndex, OverrideContextValue };
|
||||
export { OverrideContext };
|
||||
73
packages/app/src/features/overrides/hooks.ts
Normal file
73
packages/app/src/features/overrides/hooks.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useContext } from "react"
|
||||
import { useAsyncCallback } from "../async";
|
||||
import { Time } from "../data";
|
||||
import { Override, OverrideContext } from "./context"
|
||||
|
||||
export const useOverrides = () => {
|
||||
const { overrides } = useContext(OverrideContext);
|
||||
return overrides;
|
||||
}
|
||||
|
||||
export const useSetOverride = () => {
|
||||
const { set } = useContext(OverrideContext);
|
||||
return set;
|
||||
}
|
||||
|
||||
export const useGetOverride = () => {
|
||||
const { get } = useContext(OverrideContext);
|
||||
return get;
|
||||
}
|
||||
|
||||
export const useSetTaskOverride = () => {
|
||||
const { set } = useContext(OverrideContext);
|
||||
const setTaskOverride = useAsyncCallback(
|
||||
async (id: string, overrides: Override) => {
|
||||
set(current => ({
|
||||
...current,
|
||||
tasks: {
|
||||
...current.tasks,
|
||||
[id]: overrides,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[set],
|
||||
);
|
||||
return setTaskOverride;
|
||||
}
|
||||
|
||||
export const useClearTaskOverride = () => {
|
||||
const { set } = useContext(OverrideContext);
|
||||
const clearTaskOverride = useAsyncCallback(
|
||||
async (id: string) => {
|
||||
set(current => {
|
||||
const tasks = {...current.tasks};
|
||||
delete tasks[id]
|
||||
return {
|
||||
...current,
|
||||
tasks,
|
||||
};
|
||||
});
|
||||
},
|
||||
[set],
|
||||
);
|
||||
return clearTaskOverride;
|
||||
}
|
||||
|
||||
export const useStartTimeOverride = () => {
|
||||
const { overrides } = useContext(OverrideContext);
|
||||
return overrides.startTime;
|
||||
};
|
||||
|
||||
export const useSetStartTimeOverride = () => {
|
||||
const { set } = useContext(OverrideContext);
|
||||
const setStartTime = useAsyncCallback(
|
||||
async (startTime?: Time) => {
|
||||
set(current => ({
|
||||
...current,
|
||||
startTime,
|
||||
}));
|
||||
},
|
||||
[set],
|
||||
);
|
||||
return setStartTime;
|
||||
};
|
||||
3
packages/app/src/features/overrides/index.ts
Normal file
3
packages/app/src/features/overrides/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { Override, OverrideIndex } from './context';
|
||||
export { OverrideProvider } from './provider';
|
||||
export * from './hooks';
|
||||
59
packages/app/src/features/overrides/provider.tsx
Normal file
59
packages/app/src/features/overrides/provider.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import AsyncStorageLib from "@react-native-async-storage/async-storage";
|
||||
import React, { ReactNode, SetStateAction, useCallback, useState } from "react";
|
||||
import { useAsync } from "../async";
|
||||
import { Day, useDate, dayUtils } from "../day";
|
||||
import { Override, OverrideContext, OverrideIndex } from "./context";
|
||||
|
||||
type OverrideProviderProps = {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const StorageKey = 'overrides';
|
||||
|
||||
const OverrideProvider: React.FC<OverrideProviderProps> = ({ children }) => {
|
||||
const currentDate = useDate();
|
||||
const [overrides, setOverrides] = useState<OverrideIndex>();
|
||||
|
||||
const get = useCallback(
|
||||
async (date: Day): Promise<OverrideIndex> => {
|
||||
const raw = await AsyncStorageLib.getItem(`${StorageKey}_${dayUtils.toId(date)}`);
|
||||
if (!raw) {
|
||||
return { tasks: {} };
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const set = useCallback(
|
||||
async (override: SetStateAction<OverrideIndex>) => {
|
||||
const next = typeof override === 'function' ? override(overrides!) : overrides;
|
||||
setOverrides(next);
|
||||
await AsyncStorageLib.setItem(
|
||||
`${StorageKey}_${dayUtils.toId(currentDate)}`,
|
||||
JSON.stringify(next),
|
||||
);
|
||||
},
|
||||
[currentDate, overrides],
|
||||
);
|
||||
|
||||
useAsync(
|
||||
async () => {
|
||||
setOverrides(await get(currentDate));
|
||||
},
|
||||
[currentDate, setOverrides],
|
||||
);
|
||||
|
||||
if (!overrides) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<OverrideContext.Provider value={{ overrides, get, set }}>
|
||||
{children}
|
||||
</OverrideContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type { OverrideProviderProps };
|
||||
export { OverrideProvider };
|
||||
161
packages/app/src/features/planner/algorithm/build-graph.ts
Normal file
161
packages/app/src/features/planner/algorithm/build-graph.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Task, Time, UserLocation } from "#/features/data";
|
||||
import { Context, GraphNode } from "../types";
|
||||
import { getImpossible, getNext } from "./get-next";
|
||||
|
||||
enum Strategies {
|
||||
all = 'all',
|
||||
allValid = 'all-valid',
|
||||
firstValid = 'first-valid',
|
||||
firstComplet = 'first-complete',
|
||||
}
|
||||
type RunningStatus = {
|
||||
current: 'running';
|
||||
nodes: number;
|
||||
start: Date;
|
||||
strategy: Strategies,
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
type CompletedStatus = {
|
||||
current: 'completed';
|
||||
start: Date;
|
||||
end: Date;
|
||||
nodes: number;
|
||||
strategy: Strategies,
|
||||
}
|
||||
|
||||
type Status = RunningStatus | CompletedStatus;
|
||||
|
||||
type BuildGraphOptions = {
|
||||
location: UserLocation;
|
||||
time: Time;
|
||||
tasks: Task[];
|
||||
context: Context;
|
||||
strategy?: Strategies;
|
||||
batchSize?: number;
|
||||
sleepTime?: number;
|
||||
callback?: (status: Status) => void;
|
||||
};
|
||||
|
||||
const sleep = (time: number) => new Promise(resolve => setTimeout(resolve, time));
|
||||
|
||||
const fil = <T>(
|
||||
fn: ((item: T) => boolean)[],
|
||||
input: T[],
|
||||
): T[][] => {
|
||||
const output: T[][] = new Array(fn.length).fill(undefined).map(() => []);
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
for (let b = 0; b < fn.length; b++) {
|
||||
if (fn[b](input[i])) {
|
||||
output[b].push(input[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
const buildGraph = async ({
|
||||
location,
|
||||
time,
|
||||
tasks,
|
||||
context,
|
||||
strategy = Strategies.allValid,
|
||||
callback,
|
||||
batchSize = 1000,
|
||||
sleepTime = 10,
|
||||
}: BuildGraphOptions) => {
|
||||
const start = new Date();
|
||||
let nodeCount = 0;
|
||||
let running = true;
|
||||
const { remaining, impossible } = getImpossible(tasks, time);
|
||||
let leafList: GraphNode[] = [{
|
||||
location,
|
||||
time: {
|
||||
end: time,
|
||||
start: time,
|
||||
},
|
||||
score: 0,
|
||||
remainingTasks: remaining,
|
||||
impossibeTasks: impossible,
|
||||
status: {
|
||||
dead: false,
|
||||
completed: false,
|
||||
},
|
||||
}];
|
||||
const completedList: GraphNode[] = [];
|
||||
const deadList: GraphNode[] = [];
|
||||
|
||||
const complete = (nodes: GraphNode[]) => {
|
||||
if (callback) {
|
||||
callback({
|
||||
current: 'completed',
|
||||
nodes: nodeCount,
|
||||
start,
|
||||
end: new Date(),
|
||||
strategy,
|
||||
});
|
||||
}
|
||||
return nodes.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
nodeCount++;
|
||||
if (!running) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
leafList.length === 0
|
||||
&& completedList.length === 0
|
||||
&& strategy !== Strategies.all
|
||||
) {
|
||||
strategy = Strategies.all;
|
||||
leafList.push(...deadList);
|
||||
}
|
||||
const node = leafList.pop();
|
||||
if (!node) {
|
||||
break;
|
||||
}
|
||||
if (nodeCount % batchSize === 0) {
|
||||
if (callback) {
|
||||
callback({
|
||||
current: 'running',
|
||||
nodes: nodeCount,
|
||||
strategy,
|
||||
start,
|
||||
cancel: () => {
|
||||
running = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
await sleep(sleepTime);
|
||||
}
|
||||
const next = await getNext(node, context);
|
||||
const [alive, completed, dead] = fil([
|
||||
n => (strategy === Strategies.all || !n.status.dead) && !n.status.completed,
|
||||
n => !!n.status.completed && (strategy === Strategies.all || !n.status.dead),
|
||||
n => n.status.dead,
|
||||
], next);
|
||||
leafList.push(...alive);
|
||||
if (strategy === Strategies.firstValid && completed.length > 0) {
|
||||
return complete(completed);
|
||||
}
|
||||
if (completed.length > 0) {
|
||||
completedList.push(...completed)
|
||||
}
|
||||
if (strategy === Strategies.firstComplet) {
|
||||
const fullComplete = completed.find(c => c.impossibeTasks.length === 0);
|
||||
if (fullComplete) {
|
||||
return complete([fullComplete]);
|
||||
}
|
||||
}
|
||||
if (strategy !== Strategies.all) {
|
||||
deadList.push(...dead);
|
||||
}
|
||||
}
|
||||
|
||||
return complete(completedList);
|
||||
}
|
||||
|
||||
export type { Status, BuildGraphOptions };
|
||||
export { buildGraph, Strategies };
|
||||
33
packages/app/src/features/planner/algorithm/construct-day.ts
Normal file
33
packages/app/src/features/planner/algorithm/construct-day.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { timeUtils } from "#/features/data";
|
||||
import { GraphNode, PlannedEntry } from "../types";
|
||||
|
||||
const constructDay = (node: GraphNode) => {
|
||||
let current: GraphNode | undefined = node;
|
||||
const plans: PlannedEntry[] = [];
|
||||
|
||||
while(current) {
|
||||
if (current.task) {
|
||||
plans.push({
|
||||
type: 'task',
|
||||
name: current.task?.title || 'start',
|
||||
start: timeUtils.add(current.time.start, (current.transition?.time || 0)),
|
||||
end: current.time.end,
|
||||
score: current.score,
|
||||
})
|
||||
}
|
||||
if (current.transition) {
|
||||
plans.push({
|
||||
type: 'transition',
|
||||
start: current.time.start,
|
||||
end: timeUtils.add(current.time.start, current.transition.time),
|
||||
from: current.transition.from,
|
||||
to: current.transition.to,
|
||||
})
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return plans.reverse();
|
||||
}
|
||||
|
||||
export { constructDay };
|
||||
141
packages/app/src/features/planner/algorithm/get-next.ts
Normal file
141
packages/app/src/features/planner/algorithm/get-next.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Task, Time, timeUtils } from '#/features/data';
|
||||
import { Transition } from '#/features/location';
|
||||
import { Context, GraphNode } from '../types';
|
||||
import { getRemainingLocations, listContainLocation } from './utils';
|
||||
|
||||
const DEFAULT_PRIORITY = 50;
|
||||
|
||||
const isDead = (impossible: Task[]) => {
|
||||
const missingRequered = impossible.find(t => t.required);
|
||||
return !!missingRequered;
|
||||
}
|
||||
|
||||
type GetImpossibleResult = {
|
||||
remaining: Task[];
|
||||
impossible: Task[];
|
||||
}
|
||||
|
||||
export const getImpossible = (
|
||||
tasks: Task[],
|
||||
time: Time,
|
||||
) => {
|
||||
const result: GetImpossibleResult = {
|
||||
remaining: [],
|
||||
impossible: [],
|
||||
}
|
||||
|
||||
for (let task of tasks) {
|
||||
if (timeUtils.largerThan(time, task.startTime.max)) {
|
||||
result.impossible.push(task);
|
||||
} else {
|
||||
result.remaining.push(task);
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
type CalculateScoreOptions = {
|
||||
tasks?: Task[];
|
||||
transition?: Transition;
|
||||
impossible: Task[];
|
||||
}
|
||||
|
||||
const calculateScore = ({
|
||||
tasks,
|
||||
transition,
|
||||
impossible,
|
||||
}: CalculateScoreOptions) => {
|
||||
let score = 0;
|
||||
|
||||
tasks?.forEach((task) => {
|
||||
score += (task.priority || DEFAULT_PRIORITY) * 10;
|
||||
impossible.forEach((task) => {
|
||||
if (task.required) {
|
||||
score -= 10000 + (1 * (task.priority || DEFAULT_PRIORITY));
|
||||
} else {
|
||||
score -= 100 + (1 * (task.priority || DEFAULT_PRIORITY));
|
||||
}
|
||||
});
|
||||
});
|
||||
if (transition) {
|
||||
const minutes = transition.time;
|
||||
score -= 10 + (1 * minutes);
|
||||
}
|
||||
return score;
|
||||
}
|
||||
const getNext = async (
|
||||
currentNode: GraphNode,
|
||||
context: Context,
|
||||
): Promise<GraphNode[]> => {
|
||||
const nextNodes: GraphNode[] = [];
|
||||
if (!currentNode.transition) {
|
||||
const remainingLocations = getRemainingLocations(currentNode.remainingTasks, currentNode.location);
|
||||
await Promise.all(remainingLocations.map(async(location) => {
|
||||
const transition = await context.getTransition(currentNode.location, location, currentNode.time.end);
|
||||
const endTime = timeUtils.add(currentNode.time.end, transition.time);
|
||||
const { remaining, impossible } = getImpossible(currentNode.remainingTasks, endTime);
|
||||
const score = calculateScore({
|
||||
transition,
|
||||
impossible,
|
||||
});
|
||||
nextNodes.push({
|
||||
parent: currentNode,
|
||||
location: transition.to,
|
||||
remainingTasks: remaining,
|
||||
transition,
|
||||
impossibeTasks: [
|
||||
...impossible,
|
||||
...currentNode.impossibeTasks,
|
||||
],
|
||||
score: currentNode.score + score,
|
||||
status: {
|
||||
completed: false,
|
||||
dead: false, // TODO: fix isDead(impossible),
|
||||
},
|
||||
time: {
|
||||
start: currentNode.time.end,
|
||||
end: endTime,
|
||||
},
|
||||
})
|
||||
}));
|
||||
}
|
||||
const possibleTasks = currentNode.remainingTasks.filter(task => !task.locations || listContainLocation(task.locations, currentNode.location))
|
||||
await Promise.all(possibleTasks.map(async (orgTask) => {
|
||||
const task = {...orgTask};
|
||||
let startTime =
|
||||
timeUtils.max(
|
||||
currentNode.time.end,
|
||||
task.startTime.min,
|
||||
);
|
||||
const parentRemainging = currentNode.remainingTasks.filter(t => t !== orgTask);
|
||||
let endTime = timeUtils.add(startTime, task.duration);
|
||||
const { remaining, impossible } = getImpossible(parentRemainging, endTime);
|
||||
const score = calculateScore({
|
||||
tasks: [task],
|
||||
impossible,
|
||||
});
|
||||
nextNodes.push({
|
||||
parent: currentNode,
|
||||
location: currentNode.location,
|
||||
task,
|
||||
remainingTasks: remaining,
|
||||
impossibeTasks: [
|
||||
...impossible,
|
||||
...currentNode.impossibeTasks,
|
||||
],
|
||||
score: currentNode.score + score,
|
||||
status: {
|
||||
completed: remaining.length === 0,
|
||||
dead: isDead(impossible),
|
||||
},
|
||||
time: {
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
},
|
||||
})
|
||||
}));
|
||||
return nextNodes;
|
||||
};
|
||||
|
||||
export { getNext };
|
||||
38
packages/app/src/features/planner/algorithm/utils.ts
Normal file
38
packages/app/src/features/planner/algorithm/utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { UserLocation } from "#/types/location";
|
||||
import { Task } from "#/types/task";
|
||||
|
||||
export const locationEqual = (a: UserLocation, b: UserLocation) => {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
// if (a.location === b.location) {
|
||||
// return true;
|
||||
// }
|
||||
// if (a.location && b.location && a.location.latitude === b.location.latitude && a.location.longitute === b.location.longitute) {
|
||||
// return true;
|
||||
// }
|
||||
if (a.title === b.title) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const listContainLocation = (list: UserLocation[], target: UserLocation) => {
|
||||
return !!list.find(l => locationEqual(l, target));
|
||||
}
|
||||
|
||||
export const getRemainingLocations = (tasks: Task[], current: UserLocation) => {
|
||||
const result: UserLocation[] = [];
|
||||
tasks.forEach((task) => {
|
||||
if (!task.locations) {
|
||||
return;
|
||||
}
|
||||
for (let location of task.locations) {
|
||||
if (!listContainLocation(result, location) && !locationEqual(current, location)) {
|
||||
result.push(location)
|
||||
}
|
||||
}
|
||||
})
|
||||
return result;
|
||||
};
|
||||
|
||||
21
packages/app/src/features/planner/context.ts
Normal file
21
packages/app/src/features/planner/context.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createDataContext } from '#/utils/data-context';
|
||||
import { Time } from '../data';
|
||||
import { Strategies } from "./algorithm/build-graph";
|
||||
|
||||
type PlannerOptions = {
|
||||
strategy: Strategies;
|
||||
startTime: Time;
|
||||
}
|
||||
|
||||
const {
|
||||
Context: PlannerContext,
|
||||
Provider: PlannerProvider,
|
||||
} = createDataContext<PlannerOptions>({
|
||||
createDefault: () => ({
|
||||
startTime: { hour: 7, minute: 0 },
|
||||
strategy: Strategies.firstComplet,
|
||||
}),
|
||||
});
|
||||
|
||||
export type { PlannerOptions };
|
||||
export { PlannerContext, PlannerProvider };
|
||||
222
packages/app/src/features/planner/hooks.ts
Normal file
222
packages/app/src/features/planner/hooks.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { buildGraph, Status, Strategies } from "./algorithm/build-graph";
|
||||
import { useContext } from "react";
|
||||
import { add } from 'date-fns';
|
||||
import { PlannerContext } from "./context";
|
||||
import { Task, Time, UserLocation } from "../data";
|
||||
import { useRoutines } from "../routines";
|
||||
import { useGoals } from "../goals/hooks";
|
||||
import { useAsyncCallback } from "../async";
|
||||
import { Day, dayUtils } from "../day";
|
||||
import { useGetOverride } from "../overrides";
|
||||
import { useGetAppointments } from "../appointments";
|
||||
import { useGetTransition } from "../location";
|
||||
import { PlannedEntry } from "./types";
|
||||
import { constructDay } from "./algorithm/construct-day";
|
||||
|
||||
export type PreparePlanOptions = {
|
||||
start: Day;
|
||||
end: Day;
|
||||
}
|
||||
|
||||
export type PlanOptions = PreparePlanOptions & {
|
||||
location: UserLocation;
|
||||
}
|
||||
|
||||
export type PlanResultDay = {
|
||||
day: Day;
|
||||
start: Time;
|
||||
} & ({
|
||||
status: 'waiting',
|
||||
} | {
|
||||
status: 'running',
|
||||
nodes: number;
|
||||
strategy: Strategies;
|
||||
} | {
|
||||
status: 'done';
|
||||
nodes: number;
|
||||
strategy: Strategies;
|
||||
plan: PlannedEntry[];
|
||||
impossible: Task[];
|
||||
});
|
||||
|
||||
export type PlanResult = {
|
||||
impossible: Task[];
|
||||
days: {
|
||||
[day: string]: PlanResultDay;
|
||||
}
|
||||
}
|
||||
|
||||
const getDays = (start: Day, end: Day): Day[] => {
|
||||
const result: Day[] = [];
|
||||
let currentDate = dayUtils.dayToDate(start);
|
||||
const stopDate = dayUtils.dayToDate(end);
|
||||
while (currentDate <= stopDate) {
|
||||
result.push(dayUtils.dateToDay(currentDate));
|
||||
currentDate = add(currentDate, {
|
||||
days: 1,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const firstValue = <T>(...args: (T | undefined)[]): T => {
|
||||
for (let arg of args) {
|
||||
if (typeof arg !== 'undefined') {
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
export const useOptions = () => {
|
||||
const { data } = useContext(PlannerContext);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const useSetOptions = () => {
|
||||
const { setData } = useContext(PlannerContext);
|
||||
return setData;
|
||||
}
|
||||
|
||||
const usePreparePlan = () => {
|
||||
const routines = useRoutines();
|
||||
const goals = useGoals();
|
||||
const getOverrides = useGetOverride();
|
||||
const [getAppontments] = useGetAppointments();
|
||||
|
||||
const preparePlan = useAsyncCallback(
|
||||
async ({ start, end }: PreparePlanOptions) => {
|
||||
const days = await Promise.all(getDays(start, end).map(async (day) => {
|
||||
const overrides = await getOverrides(day);
|
||||
const start: Time = firstValue(overrides.startTime, { hour: 7, minute: 0 });
|
||||
const appointments = await getAppontments(day);
|
||||
const tasks = [...routines, ...appointments].map<Task | undefined>((task) => {
|
||||
const override = overrides.tasks[task.id];
|
||||
if (override?.enabled === false) {
|
||||
return undefined;
|
||||
}
|
||||
const result: Task = {
|
||||
...task,
|
||||
startTime: {
|
||||
min: firstValue(override?.startMin, task.startTime.min),
|
||||
max: firstValue(override?.startMax, task.startTime.max),
|
||||
},
|
||||
duration: firstValue(override?.duration, task.duration),
|
||||
required: firstValue(override?.required, task.required),
|
||||
}
|
||||
return result;
|
||||
}).filter(Boolean).map(a => a as Exclude<typeof a, undefined>);
|
||||
|
||||
return {
|
||||
day,
|
||||
start,
|
||||
tasks,
|
||||
}
|
||||
|
||||
}));
|
||||
return {
|
||||
goals: [...goals],
|
||||
days,
|
||||
}
|
||||
},
|
||||
[routines, goals, getOverrides, getAppontments],
|
||||
);
|
||||
|
||||
return preparePlan;
|
||||
}
|
||||
|
||||
export const usePlanOptions = () => {
|
||||
const { data } = useContext(PlannerContext);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const useSetPlanOptions = () => {
|
||||
const { setData } = useContext(PlannerContext);
|
||||
return setData;
|
||||
}
|
||||
|
||||
export const usePlan = () => {
|
||||
const [preparePlan] = usePreparePlan();
|
||||
const getTransition = useGetTransition();
|
||||
const options = usePlanOptions();
|
||||
const createPlan = useAsyncCallback(
|
||||
async ({ location, ...prepareOptions}: PlanOptions) => {
|
||||
const prepared = await preparePlan(prepareOptions);
|
||||
let result: PlanResult = {
|
||||
impossible: [],
|
||||
days: prepared.days.reduce((output, current) => ({
|
||||
...output,
|
||||
[dayUtils.toId(current.day)]: {
|
||||
day: current.day,
|
||||
start: current.start,
|
||||
status: 'waiting',
|
||||
},
|
||||
}), {} as {[name: string]: PlanResultDay})
|
||||
}
|
||||
const update = (next: PlanResult) => {
|
||||
result = next;
|
||||
}
|
||||
for (let day of prepared.days) {
|
||||
const id = dayUtils.toId(day.day);
|
||||
const dayGoal = prepared.goals;
|
||||
const graph = await buildGraph({
|
||||
location,
|
||||
time: day.start,
|
||||
tasks: [...day.tasks, ...dayGoal],
|
||||
strategy: options.strategy,
|
||||
context: {
|
||||
getTransition,
|
||||
},
|
||||
callback: (status) => {
|
||||
update({
|
||||
...result,
|
||||
days: {
|
||||
...result.days,
|
||||
[id]: {
|
||||
day: day.day,
|
||||
start: day.start,
|
||||
status: 'running',
|
||||
nodes: status.nodes,
|
||||
strategy: status.strategy,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
const [winner] = graph;
|
||||
if (!winner) {
|
||||
continue;
|
||||
}
|
||||
const plan = constructDay(winner);
|
||||
update({
|
||||
...result,
|
||||
days: {
|
||||
...result.days,
|
||||
[id]: {
|
||||
...result.days[id],
|
||||
impossible: winner.impossibeTasks,
|
||||
status: 'done',
|
||||
plan,
|
||||
}
|
||||
}
|
||||
})
|
||||
prepared.goals = prepared.goals.filter((goal) => {
|
||||
if (!dayGoal.find(d => d.id === goal.id)) {
|
||||
return true;
|
||||
}
|
||||
if (!winner.impossibeTasks.find(d => d.id === goal.id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
impossible: prepared.goals,
|
||||
};
|
||||
},
|
||||
[preparePlan, getTransition, options],
|
||||
);
|
||||
return createPlan;
|
||||
}
|
||||
3
packages/app/src/features/planner/index.ts
Normal file
3
packages/app/src/features/planner/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PlannerProvider, PlannerOptions } from './context';
|
||||
export { Strategies } from './algorithm/build-graph';
|
||||
export * from './hooks';
|
||||
49
packages/app/src/features/planner/types.ts
Normal file
49
packages/app/src/features/planner/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Task, Time, UserLocation } from "../data";
|
||||
import { GetTransition, Transition } from "../location";
|
||||
|
||||
type Context = {
|
||||
getTransition: GetTransition;
|
||||
};
|
||||
|
||||
export type PlannedTask = {
|
||||
type: 'task';
|
||||
name: string;
|
||||
start: Time;
|
||||
external?: boolean;
|
||||
end: Time;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export type PlannedTransition = {
|
||||
type: 'transition';
|
||||
start: Time;
|
||||
end: Time;
|
||||
from: UserLocation;
|
||||
to: UserLocation;
|
||||
};
|
||||
|
||||
export type PlannedEntry = PlannedTask | PlannedTransition;
|
||||
|
||||
type GraphNode = {
|
||||
location: UserLocation;
|
||||
task?: Task;
|
||||
transition?: Transition;
|
||||
parent?: GraphNode;
|
||||
remainingTasks: Task[];
|
||||
impossibeTasks: Task[];
|
||||
score: number;
|
||||
time: {
|
||||
start: Time;
|
||||
end: Time;
|
||||
};
|
||||
status: {
|
||||
dead: boolean;
|
||||
completed: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export type {
|
||||
GraphNode,
|
||||
Context,
|
||||
};
|
||||
11
packages/app/src/features/routines/context.ts
Normal file
11
packages/app/src/features/routines/context.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createDataContext } from "#/utils/data-context";
|
||||
import { Routine } from "../data";
|
||||
|
||||
const {
|
||||
Context: RoutinesContext,
|
||||
Provider: RoutinesProvider,
|
||||
}= createDataContext<{[id: string]: Routine}>({
|
||||
createDefault: () => ({}),
|
||||
})
|
||||
|
||||
export { RoutinesContext, RoutinesProvider };
|
||||
41
packages/app/src/features/routines/hooks.ts
Normal file
41
packages/app/src/features/routines/hooks.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useContext, useMemo } from "react"
|
||||
import { Routine } from "../data";
|
||||
import { RoutinesContext } from "./context"
|
||||
|
||||
export const useRoutines = () => {
|
||||
const { data } = useContext(RoutinesContext);
|
||||
const current = useMemo(
|
||||
() => Object.values(data),
|
||||
[data],
|
||||
)
|
||||
return current;
|
||||
};
|
||||
|
||||
export const useSetRoutine = () => {
|
||||
const { setData } = useContext(RoutinesContext);
|
||||
const set = useCallback(
|
||||
(routine: Routine) => setData(current => ({
|
||||
...current,
|
||||
[routine.id]: routine,
|
||||
})),
|
||||
[setData],
|
||||
);
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
export const useRemoveRoutine = () => {
|
||||
const { setData } = useContext(RoutinesContext);
|
||||
const removeRoutine = useCallback(
|
||||
(id: string) => {
|
||||
setData(current => {
|
||||
const next = {...current};
|
||||
delete next[id];
|
||||
return next;
|
||||
})
|
||||
},
|
||||
[setData],
|
||||
);
|
||||
|
||||
return removeRoutine;
|
||||
}
|
||||
2
packages/app/src/features/routines/index.ts
Normal file
2
packages/app/src/features/routines/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { RoutinesProvider } from './context';
|
||||
export * from './hooks';
|
||||
39
packages/app/src/features/setup.tsx
Normal file
39
packages/app/src/features/setup.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ReactNode } from "react"
|
||||
import { AppointmentsProvider } from "./appointments"
|
||||
import { DateProvider } from "./day"
|
||||
import { GoalsProvider } from "./goals/context"
|
||||
import { GetTransition, LocationProvider } from "./location"
|
||||
import { OverrideProvider } from "./overrides"
|
||||
import { PlannerProvider } from "./planner"
|
||||
import { RoutinesProvider } from "./routines"
|
||||
|
||||
type SetupProps = {
|
||||
children: ReactNode;
|
||||
getTransit: GetTransition;
|
||||
}
|
||||
|
||||
const Setup: React.FC<SetupProps> = ({
|
||||
children,
|
||||
getTransit,
|
||||
}) => {
|
||||
return (
|
||||
<DateProvider>
|
||||
<PlannerProvider storageKey="planner">
|
||||
<LocationProvider getTransition={getTransit} lookup={() => []}>
|
||||
<AppointmentsProvider>
|
||||
<GoalsProvider storageKey="goals">
|
||||
<RoutinesProvider storageKey="routines">
|
||||
<OverrideProvider>
|
||||
{children}
|
||||
</OverrideProvider>
|
||||
</RoutinesProvider>
|
||||
</GoalsProvider>
|
||||
</AppointmentsProvider>
|
||||
</LocationProvider>
|
||||
</PlannerProvider>
|
||||
</DateProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SetupProps };
|
||||
export { Setup };
|
||||
63
packages/app/src/features/tasks/hooks.tsx
Normal file
63
packages/app/src/features/tasks/hooks.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAppointments } from "../appointments";
|
||||
import { useAsyncCallback } from "../async";
|
||||
import { Task, TaskType } from "../data";
|
||||
import { useGoals, useRemoveGoal, useSetGoals } from "../goals/hooks";
|
||||
import { useRemoveRoutine, useRoutines, useSetRoutine } from "../routines";
|
||||
|
||||
export const useTasks = (type?: TaskType) => {
|
||||
const [appointments] = useAppointments();
|
||||
const routines = useRoutines();
|
||||
const goals = useGoals();
|
||||
|
||||
const tasks = useMemo<Task[]>(
|
||||
() => {
|
||||
if (!type) {
|
||||
return [...(appointments || []), ...routines, ...goals];
|
||||
}
|
||||
const map = {
|
||||
[TaskType.routine]: routines,
|
||||
[TaskType.appointment]: appointments,
|
||||
[TaskType.goal]: goals,
|
||||
}
|
||||
return map[type] || [];
|
||||
},
|
||||
[appointments, routines, goals, type],
|
||||
);
|
||||
|
||||
return tasks;
|
||||
};
|
||||
|
||||
export const useSetTask = () => {
|
||||
const setRoutine = useSetRoutine();
|
||||
const setGoal = useSetGoals();
|
||||
|
||||
const result = useAsyncCallback(
|
||||
async (task: Task) => {
|
||||
if (task.type === TaskType.routine) {
|
||||
await setRoutine(task);
|
||||
} else if (task.type === TaskType.goal) {
|
||||
await setGoal(task);
|
||||
}
|
||||
},
|
||||
[setRoutine, setGoal],
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const useRemoveTask = () => {
|
||||
const removeRoutine = useRemoveRoutine();
|
||||
const removeGoal = useRemoveGoal();
|
||||
|
||||
const result = useAsyncCallback(
|
||||
async (task: Task) => {
|
||||
if (task.type === TaskType.routine) {
|
||||
removeRoutine(task.id);
|
||||
} else if (task.type === TaskType.goal) {
|
||||
removeGoal(task.id);
|
||||
}
|
||||
},
|
||||
[removeRoutine, removeGoal],
|
||||
);
|
||||
return result;
|
||||
};
|
||||
1
packages/app/src/features/tasks/index.ts
Normal file
1
packages/app/src/features/tasks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './hooks';
|
||||
10
packages/app/src/index.ts
Normal file
10
packages/app/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
import './setup';
|
||||
|
||||
import { App } from './app';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
|
||||
1
packages/app/src/setup.ts
Normal file
1
packages/app/src/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default '';
|
||||
2
packages/app/src/setup.web.ts
Normal file
2
packages/app/src/setup.web.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import '@fontsource/montserrat';
|
||||
export default '';
|
||||
17
packages/app/src/ui/components/tasks/list-item.tsx
Normal file
17
packages/app/src/ui/components/tasks/list-item.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Task } from "#/features/data";
|
||||
import { Row, RowProps } from "@morten-olsen/ui";
|
||||
|
||||
type Props = RowProps & {
|
||||
item: Task;
|
||||
}
|
||||
|
||||
const TaskListItem: React.FC<Props> = ({ item, ...rowProps }) => {
|
||||
return (
|
||||
<Row
|
||||
{...rowProps}
|
||||
title={item.title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { TaskListItem };
|
||||
29
packages/app/src/ui/containers/plan/day/index.tsx
Normal file
29
packages/app/src/ui/containers/plan/day/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { dayUtils } from "#/features/day";
|
||||
import { PlanResultDay } from "#/features/planner"
|
||||
import { Body1, Jumbo } from "@morten-olsen/ui";
|
||||
import { PlanDayTask } from "./task";
|
||||
|
||||
type Props = {
|
||||
day: PlanResultDay;
|
||||
};
|
||||
|
||||
const PlanDay: React.FC<Props> = ({ day }) => {
|
||||
if (day.status === 'waiting') {
|
||||
return <></>
|
||||
}
|
||||
if (day.status === 'running') {
|
||||
return <Body1>Running</Body1>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{day.plan.map((item) => {
|
||||
if (item.type === 'task') {
|
||||
return <PlanDayTask task={item} />
|
||||
}
|
||||
return <Body1>Transit {item.from.title} to {item.to.title}</Body1>
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { PlanDay };
|
||||
89
packages/app/src/ui/containers/plan/day/task.tsx
Normal file
89
packages/app/src/ui/containers/plan/day/task.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from 'react';
|
||||
import { PlannedTask } from "#/features/planner/types";
|
||||
import chroma from 'chroma-js';
|
||||
import styled from "styled-components/native";
|
||||
import stringToColor from 'string-to-color';
|
||||
import { timeUtils } from '#/features/data';
|
||||
import { Body1, CalendarEntry, Row } from '@morten-olsen/ui';
|
||||
|
||||
type Props = {
|
||||
task: PlannedTask;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
const Time = styled.Text<{background : string}>`
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
const TimeBox = styled.View<{
|
||||
background: string;
|
||||
}>`
|
||||
margin-right: 10px;
|
||||
width: 50px;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const Filler = styled.View`
|
||||
margin: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const Block = styled.View<{
|
||||
background: string;
|
||||
height: number;
|
||||
}>`
|
||||
background: ${({ background }) => background};
|
||||
height: ${({ height }) => height / 3}px;
|
||||
max-height: 100px;
|
||||
margin: 5px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
border: solid 1px ${({ background }) => background === 'transparent' ? background : chroma(background).darken(0.3).hex()};
|
||||
`;
|
||||
|
||||
const Main = styled.View`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const Touch = styled.TouchableOpacity`
|
||||
`;
|
||||
|
||||
const PlanDayTask: React.FC<Props> = ({ task, onPress }) => {
|
||||
const color = useMemo(
|
||||
() => chroma(stringToColor(task.name)).luminance(0.7).saturate(1).brighten(0.6).hex(),
|
||||
[task.name],
|
||||
);
|
||||
const height = useMemo(
|
||||
() => (timeUtils.timeToMinutes(task.end) - timeUtils.timeToMinutes(task.start)) / 10,
|
||||
[task.start, task.end],
|
||||
);
|
||||
const view = (
|
||||
<Row>
|
||||
<CalendarEntry
|
||||
location={task.location?.join(', ') || 'anywhere'}
|
||||
start={timeUtils.timeToDate(task.start)}
|
||||
end={timeUtils.timeToDate(task.end)}
|
||||
title={task.name}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<Touch onPress={onPress}>
|
||||
{view}
|
||||
</Touch>
|
||||
);
|
||||
}
|
||||
return view;
|
||||
};
|
||||
|
||||
export { PlanDayTask };
|
||||
1
packages/app/src/ui/containers/plan/index.ts
Normal file
1
packages/app/src/ui/containers/plan/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './day';
|
||||
41
packages/app/src/ui/containers/tasks/group.tsx
Normal file
41
packages/app/src/ui/containers/tasks/group.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { TaskType } from "#/features/data";
|
||||
import { useTasks } from "#/features/tasks";
|
||||
import { Group } from "@morten-olsen/ui"
|
||||
import { RootNavigationProp } from "#/ui/router";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type Props = {
|
||||
type: TaskType;
|
||||
}
|
||||
|
||||
const TaskGroup: React.FC<Props> = ({ type }) => {
|
||||
const { navigate } = useNavigation<RootNavigationProp>();
|
||||
const tasks = useTasks(type);
|
||||
|
||||
const add = useCallback(
|
||||
(type: TaskType) => {
|
||||
navigate('add-task', {
|
||||
type,
|
||||
})
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Group
|
||||
title={type}
|
||||
add={() => add(type)}
|
||||
items={tasks || []}
|
||||
getKey={(task) => task.id}
|
||||
render={(task) => ({
|
||||
title: task.title,
|
||||
onPress: () => {
|
||||
navigate('set-override', { id: task.id });
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { TaskGroup };
|
||||
1
packages/app/src/ui/index.ts
Normal file
1
packages/app/src/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/base';
|
||||
2
packages/app/src/ui/router/index.tsx
Normal file
2
packages/app/src/ui/router/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Router } from './router';
|
||||
export * from './types';
|
||||
112
packages/app/src/ui/router/router.tsx
Normal file
112
packages/app/src/ui/router/router.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { useTheme } from 'styled-components/native';
|
||||
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { Icon } from '@morten-olsen/ui/components/base';
|
||||
import { DayScreen } from '../screens/day';
|
||||
import { TaskAddScreen } from '../screens/task/add';
|
||||
import { MainTabParamList, RootStackParamList } from './types';
|
||||
import { Platform } from 'react-native';
|
||||
import { MoreScreen } from '../screens/more';
|
||||
import { LocationListScreen } from '../screens/locations/list';
|
||||
import { LocationSetScreen } from '../screens/locations/set';
|
||||
import { TaskListScreen } from '../screens/task/list';
|
||||
import { OverrideSetScreen } from '../screens/task/overrides';
|
||||
import { PlanScreen } from '../screens/plan';
|
||||
|
||||
const MoreStackNavigator = createNativeStackNavigator();
|
||||
|
||||
const MoreStack: React.FC = () => (
|
||||
<MoreStackNavigator.Navigator>
|
||||
<MoreStackNavigator.Screen name="more-main" component={MoreScreen} />
|
||||
<MoreStackNavigator.Screen name="locations" component={LocationListScreen} />
|
||||
<MoreStackNavigator.Screen name="tasks" component={TaskListScreen} />
|
||||
</MoreStackNavigator.Navigator>
|
||||
);
|
||||
|
||||
const MainTabsNvaigator = createBottomTabNavigator<MainTabParamList>();
|
||||
|
||||
const MainTabs: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<MainTabsNvaigator.Navigator
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.colors.primary,
|
||||
}}
|
||||
>
|
||||
<MainTabsNvaigator.Screen
|
||||
options={{
|
||||
headerShown: false,
|
||||
tabBarLabel: 'Days',
|
||||
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="check-square" />,
|
||||
}}
|
||||
name="day"
|
||||
component={DayScreen}
|
||||
/>
|
||||
<MainTabsNvaigator.Screen
|
||||
options={{
|
||||
headerShown: false,
|
||||
tabBarLabel: 'Plan',
|
||||
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="check-square" />,
|
||||
}}
|
||||
name="plan"
|
||||
component={PlanScreen}
|
||||
/>
|
||||
<MainTabsNvaigator.Screen
|
||||
options={{
|
||||
headerShown: false,
|
||||
tabBarLabel: 'More',
|
||||
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="more-vertical" />,
|
||||
}}
|
||||
name="more"
|
||||
component={MoreStack}
|
||||
/>
|
||||
</MainTabsNvaigator.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
const RootNavigator = Platform.OS === 'web'
|
||||
? createStackNavigator<RootStackParamList>()
|
||||
: createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
const Root: React.FC = () => (
|
||||
<RootNavigator.Navigator screenOptions={{ headerShown: false, animationEnabled: true }}>
|
||||
<RootNavigator.Group>
|
||||
<RootNavigator.Screen name="main" component={MainTabs} />
|
||||
</RootNavigator.Group>
|
||||
<RootNavigator.Group screenOptions={{ presentation: 'transparentModal' }}>
|
||||
<RootNavigator.Screen name="add-task" component={TaskAddScreen} />
|
||||
<RootNavigator.Screen name="set-location" component={LocationSetScreen} />
|
||||
<RootNavigator.Screen name="set-override" component={OverrideSetScreen} />
|
||||
</RootNavigator.Group>
|
||||
</RootNavigator.Navigator>
|
||||
);
|
||||
|
||||
const Router: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const baseTheme = useMemo(
|
||||
() => DefaultTheme,
|
||||
[],
|
||||
);
|
||||
const navigationTheme = useMemo(
|
||||
() => ({
|
||||
...baseTheme,
|
||||
colors: {
|
||||
...baseTheme.colors,
|
||||
background: theme.colors.shade,
|
||||
card: theme.colors.background,
|
||||
text: theme.colors.text,
|
||||
}
|
||||
}),
|
||||
[baseTheme, theme],
|
||||
);
|
||||
return (
|
||||
<NavigationContainer theme={navigationTheme}>
|
||||
<Root />
|
||||
</NavigationContainer>
|
||||
)
|
||||
};
|
||||
|
||||
export { Router };
|
||||
51
packages/app/src/ui/router/types.ts
Normal file
51
packages/app/src/ui/router/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { TaskType } from "#/features/data";
|
||||
import { NavigatorScreenParams, RouteProp } from "@react-navigation/native";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
|
||||
export type RootStackParamList = {
|
||||
main: undefined;
|
||||
'add-task': {
|
||||
type: TaskType;
|
||||
} | {
|
||||
id: string;
|
||||
};
|
||||
'set-location': {
|
||||
id?: string;
|
||||
};
|
||||
'set-override': {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MainTabParamList = {
|
||||
day: NavigatorScreenParams<RootStackParamList>;
|
||||
plan: NavigatorScreenParams<RootStackParamList>;
|
||||
more: NavigatorScreenParams<RootStackParamList>;
|
||||
}
|
||||
|
||||
export type MoreStackParamList = {
|
||||
locations: undefined;
|
||||
tasks: {
|
||||
type: TaskType;
|
||||
};
|
||||
}
|
||||
|
||||
export type MoreScreenNavigationProps = NativeStackNavigationProp<
|
||||
MoreStackParamList
|
||||
>;
|
||||
|
||||
|
||||
export type RootRouteProp = RouteProp<RootStackParamList>;
|
||||
export type RootNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export type LocationSetScreenRouteProp = RouteProp<RootStackParamList, 'set-location'>;
|
||||
|
||||
export type TaskAddScreenRouteProp = RouteProp<RootStackParamList, 'add-task'>;
|
||||
export type TaskAddScreenNavigationProp = NativeStackNavigationProp<
|
||||
RootStackParamList,
|
||||
'add-task'
|
||||
>;
|
||||
|
||||
export type TaskListScreenRouteProp = RouteProp<MoreStackParamList, 'tasks'>;
|
||||
|
||||
export type DayScreenRouteProp = RouteProp<MainTabParamList, 'day'>;
|
||||
50
packages/app/src/ui/screens/day/index.tsx
Normal file
50
packages/app/src/ui/screens/day/index.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useAppointmentStatus } from "#/features/appointments";
|
||||
import { AppointmentsStatus } from "#/features/appointments/context";
|
||||
import { TaskType } from "#/features/data";
|
||||
import { dayUtils, useDate, useSetDate } from "#/features/day";
|
||||
import { useSetStartTimeOverride, useStartTimeOverride } from "#/features/overrides";
|
||||
import { CalendarStrip } from "@morten-olsen/ui/components/date"
|
||||
import { TimeInput } from "@morten-olsen/ui/components/form";
|
||||
import { TaskGroup } from "#/ui/containers/tasks/group";
|
||||
import styled from "styled-components/native";
|
||||
|
||||
const Wrapper = styled.View`
|
||||
margin-top: 30px;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const Content = styled.ScrollView`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const DayScreen: React.FC = () => {
|
||||
const date = useDate();
|
||||
const setDate = useSetDate();
|
||||
const appointmentStatus = useAppointmentStatus();
|
||||
const startTimeOverride = useStartTimeOverride();
|
||||
const [setStartTimeOverride] = useSetStartTimeOverride();
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<CalendarStrip
|
||||
selected={dayUtils.dayToDate(date)}
|
||||
onSelect={(date) => setDate(dayUtils.dateToDay(date))}
|
||||
/>
|
||||
<Content>
|
||||
<TimeInput
|
||||
key={dayUtils.toId(date)}
|
||||
label="Start time"
|
||||
value={startTimeOverride}
|
||||
onChange={setStartTimeOverride}
|
||||
/>
|
||||
{appointmentStatus === AppointmentsStatus.rejected && (
|
||||
<TaskGroup type={TaskType.appointment} />
|
||||
)}
|
||||
<TaskGroup type={TaskType.routine} />
|
||||
<TaskGroup type={TaskType.goal} />
|
||||
</Content>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export { DayScreen };
|
||||
25
packages/app/src/ui/screens/locations/list.tsx
Normal file
25
packages/app/src/ui/screens/locations/list.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useLocations, useRemoveLocation } from "#/features/location"
|
||||
import { List, Page } from "@morten-olsen/ui/components/base";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
|
||||
const LocationListScreen: React.FC = () => {
|
||||
const { navigate } = useNavigation();
|
||||
const locations = useLocations();
|
||||
const removeLocation = useRemoveLocation();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<List
|
||||
items={locations}
|
||||
remove={l => removeLocation(l.id)}
|
||||
getKey={l => l.id}
|
||||
add={() => navigate('set-location', {})}
|
||||
render={(item) => ({
|
||||
title: item.title,
|
||||
})}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export { LocationListScreen };
|
||||
56
packages/app/src/ui/screens/locations/set.tsx
Normal file
56
packages/app/src/ui/screens/locations/set.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useAsyncCallback } from "#/features/async";
|
||||
import { useLocations, useSetLocation } from "#/features/location"
|
||||
import { Button, Popup, Row } from "@morten-olsen/ui/components/base";
|
||||
import { TextInput } from "@morten-olsen/ui/components/form";
|
||||
import { LocationSetScreenRouteProp, RootNavigationProp } from "#/ui/router";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const LocationSetScreen: React.FC = () => {
|
||||
const {
|
||||
params: { id = nanoid() },
|
||||
} = useRoute<LocationSetScreenRouteProp>();
|
||||
const { navigate } = useNavigation<RootNavigationProp>();
|
||||
const locations = useLocations();
|
||||
const [title, setTitle] = useState('');
|
||||
const setLocation = useSetLocation();
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const current = locations.find(l => l.id === id);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
setTitle(current.title);
|
||||
},
|
||||
[id, locations],
|
||||
)
|
||||
|
||||
const [save] = useAsyncCallback(
|
||||
async () => {
|
||||
await setLocation({
|
||||
id,
|
||||
title,
|
||||
position: { longitute: 0, latitude: 0 },
|
||||
});
|
||||
navigate('main');
|
||||
},
|
||||
[id, title],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popup title="Edit location">
|
||||
<TextInput
|
||||
label="What should it call the location?"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
/>
|
||||
<Row>
|
||||
<Button title="Save" onPress={save} />
|
||||
</Row>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export { LocationSetScreen };
|
||||
30
packages/app/src/ui/screens/more/index.tsx
Normal file
30
packages/app/src/ui/screens/more/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { TaskType } from "#/features/data";
|
||||
import { Page, Row } from "@morten-olsen/ui/components/base";
|
||||
import { MoreScreenNavigationProps } from "#/ui/router";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
|
||||
const MoreScreen: React.FC = () => {
|
||||
const { navigate } = useNavigation<MoreScreenNavigationProps>();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Row
|
||||
title="Calendars"
|
||||
/>
|
||||
<Row
|
||||
title="Locations"
|
||||
onPress={() => navigate('locations')}
|
||||
/>
|
||||
<Row
|
||||
title="Routines"
|
||||
onPress={() => navigate('tasks', { type: TaskType.routine })}
|
||||
/>
|
||||
<Row
|
||||
title="Goals"
|
||||
onPress={() => navigate('tasks', { type: TaskType.goal })}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export { MoreScreen };
|
||||
69
packages/app/src/ui/screens/plan/index.tsx
Normal file
69
packages/app/src/ui/screens/plan/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Day, dayUtils, useDate } from "#/features/day"
|
||||
import { usePlan } from "#/features/planner";
|
||||
import { Button, Row } from "@morten-olsen/ui/components/base";
|
||||
import { CalendarStrip, DateSelector, FormLayout } from "@morten-olsen/ui";
|
||||
import { PlanDay } from "#/ui/containers/plan";
|
||||
import { useCallback, useState } from "react";
|
||||
import styled from "styled-components/native";
|
||||
|
||||
const Scroll = styled.ScrollView`
|
||||
flex: 1;
|
||||
`
|
||||
const Wrapper = styled.View`
|
||||
margin: 60px 0;
|
||||
`
|
||||
|
||||
const Horizontal = styled.View`
|
||||
`;
|
||||
|
||||
const FlexDateSelector = styled(DateSelector)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const PlanScreen: React.FC = () => {
|
||||
const [start, setStart] = useState<Day>(dayUtils.today());
|
||||
const [end, setEnd] = useState<Day>(dayUtils.today());
|
||||
const [plan, { result }] = usePlan();
|
||||
|
||||
const runPlanning = useCallback(
|
||||
() => plan({
|
||||
start,
|
||||
end,
|
||||
location: { id: 'sdf', title: 'sdf' },
|
||||
}),
|
||||
[start, end, plan],
|
||||
);
|
||||
|
||||
return (
|
||||
<Scroll>
|
||||
<Wrapper>
|
||||
<Horizontal>
|
||||
<FlexDateSelector
|
||||
label="Start date"
|
||||
selected={start}
|
||||
onSelect={setStart}
|
||||
/>
|
||||
<DateSelector
|
||||
label="End date"
|
||||
selected={end}
|
||||
onSelect={setEnd}
|
||||
/>
|
||||
</Horizontal>
|
||||
<Row>
|
||||
<Button onPress={runPlanning} title="Plan" />
|
||||
</Row>
|
||||
{!!result && (
|
||||
<>
|
||||
<CalendarStrip
|
||||
/>
|
||||
{Object.entries(result.days).map(([key, day]) => (
|
||||
<PlanDay day={day} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Scroll>
|
||||
);
|
||||
}
|
||||
|
||||
export { PlanScreen }
|
||||
171
packages/app/src/ui/screens/task/add.tsx
Normal file
171
packages/app/src/ui/screens/task/add.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useAsyncCallback } from "#/features/async";
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Task, TaskType, Time, UserLocation } from "#/features/data";
|
||||
import { useLocations } from "#/features/location";
|
||||
import { useSetTask, useTasks } from "#/features/tasks";
|
||||
import { Button, Cell, Group, Popup, Row } from "@morten-olsen/ui/components/base"
|
||||
import { Checkbox, TextInput, TimeInput, OptionalSelector } from "@morten-olsen/ui/components/form";
|
||||
import { RootNavigationProp, TaskAddScreenRouteProp } from "#/ui/router";
|
||||
import { Overline } from "@morten-olsen/ui/typography";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import { useEffect, useState } from "react";
|
||||
import styled from "styled-components/native";
|
||||
|
||||
const SideBySide = styled.View`
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const dayNames = [
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednsday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
'Sunday',
|
||||
]
|
||||
|
||||
const days = new Array(7).fill(undefined).map((_, i) => ({
|
||||
id: i,
|
||||
name: dayNames[i],
|
||||
}))
|
||||
|
||||
const TaskAddScreen: React.FC = () => {
|
||||
const { params: { type, id }} = useRoute<TaskAddScreenRouteProp>();
|
||||
const { navigate, goBack } = useNavigation<RootNavigationProp>();
|
||||
const [currentId, setCurrentId] = useState(id || nanoid());
|
||||
const [setTask] = useSetTask();
|
||||
const tasks = useTasks();
|
||||
const [currentType, setCurrentType] = useState<TaskType>(type);
|
||||
|
||||
const locations = useLocations();
|
||||
const [title, setTitle] = useState('');
|
||||
const [maxStart, setMaxStart] = useState<Time>();
|
||||
const [minStart, setMinStart] = useState<Time>();
|
||||
const [duration, setDuration] = useState('');
|
||||
const [hasLocation, setHasLocation] = useState(false);
|
||||
const [selectedLocations, setSelectedLocations] = useState<UserLocation[]>([]);
|
||||
const [hasDays, setHasDays] = useState(false);
|
||||
const [selectedDays, setSelectedDays] = useState<typeof days>([]);
|
||||
const [required, setRequired] = useState(false);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const current = tasks.find(t => t.id === id);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
setTitle(current.title);
|
||||
setMaxStart(current.startTime.max);
|
||||
setMinStart(current.startTime.min);
|
||||
setDuration(current.duration?.toString() || '');
|
||||
setHasLocation(!!current.locations);
|
||||
setSelectedLocations(current.locations || []);
|
||||
setCurrentType(current.type || TaskType.goal);
|
||||
setRequired(current.required);
|
||||
if (current.type === TaskType.goal || current.type === TaskType.routine) {
|
||||
setHasDays(!!current.days);
|
||||
}
|
||||
},
|
||||
[id],
|
||||
)
|
||||
|
||||
const [save] = useAsyncCallback(
|
||||
async () => {
|
||||
const task: Partial<Task> = {
|
||||
id: currentId,
|
||||
title,
|
||||
type: currentType,
|
||||
required,
|
||||
startTime: {
|
||||
max: maxStart!,
|
||||
min: minStart!,
|
||||
},
|
||||
duration: parseInt(duration),
|
||||
locations: hasLocation ? selectedLocations: undefined,
|
||||
};
|
||||
if (task.type === TaskType.goal || task.type === TaskType.routine) {
|
||||
task.days = hasDays
|
||||
? new Array(7).fill(undefined).map((_, i) => !!selectedDays.find(d => d.id === i))
|
||||
: undefined;
|
||||
}
|
||||
await setTask(task as Task);
|
||||
navigate('main');
|
||||
},
|
||||
[
|
||||
title,
|
||||
currentId,
|
||||
maxStart,
|
||||
minStart,
|
||||
duration,
|
||||
hasLocation,
|
||||
selectedLocations,
|
||||
hasDays,
|
||||
selectedDays,
|
||||
required,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popup title={type ? `Add ${type}` : `Update ${title}`} onClose={goBack}>
|
||||
<Group title="Basic">
|
||||
<TextInput label="Title" value={title} onChangeText={setTitle} />
|
||||
<SideBySide>
|
||||
<TimeInput flex={1} label="Min start" value={minStart} onChange={setMinStart} />
|
||||
<TimeInput flex={1} label="Max start" value={maxStart} onChange={setMaxStart} />
|
||||
</SideBySide>
|
||||
<TextInput
|
||||
label="Duration"
|
||||
value={duration}
|
||||
onChangeText={setDuration}
|
||||
right={<Cell><Overline>min</Overline></Cell>}
|
||||
/>
|
||||
</Group>
|
||||
<Group title="Optional" startHidden>
|
||||
<OptionalSelector
|
||||
label="Location"
|
||||
enabled={hasLocation}
|
||||
items={locations}
|
||||
selected={selectedLocations}
|
||||
onChange={setSelectedLocations}
|
||||
render={location => ({
|
||||
title: location.title,
|
||||
})}
|
||||
getKey={location => location.id}
|
||||
setEnabled={setHasLocation}
|
||||
disabledText="Anywhere"
|
||||
enabledText="Specific location"
|
||||
/>
|
||||
<OptionalSelector
|
||||
label="Days"
|
||||
enabled={hasDays}
|
||||
items={days}
|
||||
selected={selectedDays}
|
||||
onChange={setSelectedDays}
|
||||
render={day=> ({
|
||||
title: day.name
|
||||
})}
|
||||
getKey={day => day.id.toString()}
|
||||
setEnabled={setHasDays}
|
||||
disabledText="Any day"
|
||||
enabledText="Specific days"
|
||||
/>
|
||||
<Checkbox label="Required" onChange={setRequired} value={required} />
|
||||
{type === TaskType.goal && (
|
||||
<SideBySide>
|
||||
<TextInput label="Start" flex={1} />
|
||||
<TextInput label="Deadline" flex={1} />
|
||||
</SideBySide>
|
||||
)}
|
||||
</Group>
|
||||
<Row>
|
||||
<Button onPress={save} title="Save" type="primary" />
|
||||
</Row>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export { TaskAddScreen };
|
||||
32
packages/app/src/ui/screens/task/list.tsx
Normal file
32
packages/app/src/ui/screens/task/list.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useRemoveTask, useTasks } from "#/features/tasks";
|
||||
import { List, Page } from "@morten-olsen/ui/components/base";
|
||||
import { RootNavigationProp, TaskListScreenRouteProp } from "#/ui/router";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
|
||||
const TaskListScreen: React.FC = () => {
|
||||
const {
|
||||
params: { type },
|
||||
} = useRoute<TaskListScreenRouteProp>();
|
||||
const { navigate } = useNavigation<RootNavigationProp>();
|
||||
const tasks = useTasks(type);
|
||||
const [removeTask] = useRemoveTask();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<List
|
||||
items={tasks}
|
||||
remove={removeTask}
|
||||
getKey={l => l.id}
|
||||
add={() => navigate('add-task', { type })}
|
||||
render={(item) => ({
|
||||
title: item.title,
|
||||
onPress: () => {
|
||||
navigate('add-task', { id: item.id })
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export { TaskListScreen };
|
||||
171
packages/app/src/ui/screens/task/overrides.tsx
Normal file
171
packages/app/src/ui/screens/task/overrides.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useAsyncCallback } from "#/features/async";
|
||||
import { Time, timeUtils, UserLocation } from "#/features/data";
|
||||
import { useLocations } from "#/features/location";
|
||||
import { useTasks } from "#/features/tasks";
|
||||
import { Button, Cell, Popup, Row } from "@morten-olsen/ui/components/base"
|
||||
import { Checkbox, TextInput, TimeInput, OptionalSelector } from "@morten-olsen/ui/components/form";
|
||||
import { RootNavigationProp, TaskAddScreenRouteProp } from "#/ui/router";
|
||||
import { Overline } from "@morten-olsen/ui/typography";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import styled from "styled-components/native";
|
||||
import { Override, useClearTaskOverride, useOverrides, useSetTaskOverride } from "#/features/overrides";
|
||||
|
||||
const SideBySide = styled.View`
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const dayNames = [
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednsday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
'Sunday',
|
||||
]
|
||||
|
||||
const days = new Array(7).fill(undefined).map((_, i) => ({
|
||||
id: i,
|
||||
name: dayNames[i],
|
||||
}))
|
||||
|
||||
const OverrideSetScreen: React.FC = () => {
|
||||
const { params: { id }} = useRoute<TaskAddScreenRouteProp>();
|
||||
const { navigate, goBack } = useNavigation<RootNavigationProp>();
|
||||
const [setOverride] = useSetTaskOverride()
|
||||
const [clearOverrides] = useClearTaskOverride();
|
||||
const overrides = useOverrides();
|
||||
const tasks = useTasks();
|
||||
const task = useMemo(
|
||||
() => tasks.find(t => t.id === id)!,
|
||||
[tasks, id],
|
||||
);
|
||||
|
||||
const locations = useLocations();
|
||||
const [maxStart, setMaxStart] = useState<Time>();
|
||||
const [minStart, setMinStart] = useState<Time>();
|
||||
const [duration, setDuration] = useState('');
|
||||
const [hasLocation, setHasLocation] = useState(false);
|
||||
const [selectedLocations, setSelectedLocations] = useState<UserLocation[]>([]);
|
||||
const [hasDays, setHasDays] = useState(false);
|
||||
const [selectedDays, setSelectedDays] = useState<typeof days>([]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const current = overrides.tasks[id];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
setMinStart(current.startMin);
|
||||
setMaxStart(current.startMax);
|
||||
setDuration(current.duration?.toString() || '');
|
||||
setHasLocation(!!current.locations);
|
||||
setSelectedLocations(current.locations || []);
|
||||
},
|
||||
[id],
|
||||
)
|
||||
|
||||
const [save] = useAsyncCallback(
|
||||
async () => {
|
||||
const override: Override = {
|
||||
startMin: minStart,
|
||||
startMax: maxStart,
|
||||
duration: duration ? parseInt(duration) : undefined,
|
||||
locations: hasLocation ? selectedLocations: undefined,
|
||||
};
|
||||
await setOverride(id, override);
|
||||
navigate('main');
|
||||
},
|
||||
[
|
||||
id,
|
||||
maxStart,
|
||||
minStart,
|
||||
duration,
|
||||
hasLocation,
|
||||
selectedLocations,
|
||||
hasDays,
|
||||
selectedDays,
|
||||
],
|
||||
);
|
||||
|
||||
const [clear] = useAsyncCallback(
|
||||
async () => {
|
||||
await clearOverrides(id);
|
||||
navigate('main');
|
||||
},
|
||||
[id, clearOverrides],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popup title={`Overrides for ${task.title}`} onClose={goBack}>
|
||||
<SideBySide>
|
||||
<TimeInput
|
||||
flex={1}
|
||||
placeholder={task.startTime.min ? timeUtils.timeToString(task.startTime.min) : undefined}
|
||||
label="Min start"
|
||||
value={minStart}
|
||||
onChange={setMinStart}
|
||||
/>
|
||||
<TimeInput
|
||||
flex={1}
|
||||
placeholder={task.startTime.max ? timeUtils.timeToString(task.startTime.max) : undefined}
|
||||
label="Max start"
|
||||
value={maxStart}
|
||||
onChange={setMaxStart}
|
||||
/>
|
||||
</SideBySide>
|
||||
<TextInput
|
||||
label="Duration"
|
||||
value={duration}
|
||||
onChangeText={setDuration}
|
||||
right={<Cell><Overline>min</Overline></Cell>}
|
||||
/>
|
||||
<OptionalSelector
|
||||
label="Location"
|
||||
enabled={hasLocation}
|
||||
items={locations}
|
||||
selected={selectedLocations}
|
||||
onChange={setSelectedLocations}
|
||||
render={location => ({
|
||||
title: location.title,
|
||||
})}
|
||||
getKey={location => location.id}
|
||||
setEnabled={setHasLocation}
|
||||
disabledText="Anywhere"
|
||||
enabledText="Specific location"
|
||||
/>
|
||||
<OptionalSelector
|
||||
label="Days"
|
||||
enabled={hasDays}
|
||||
items={days}
|
||||
selected={selectedDays}
|
||||
onChange={setSelectedDays}
|
||||
render={day=> ({
|
||||
title: day.name
|
||||
})}
|
||||
getKey={day => day.id.toString()}
|
||||
setEnabled={setHasDays}
|
||||
disabledText="Any day"
|
||||
enabledText="Specific days"
|
||||
/>
|
||||
<Checkbox label="Required" />
|
||||
<TextInput
|
||||
label="Priority"
|
||||
placeholder="5"
|
||||
/>
|
||||
<Row>
|
||||
<SideBySide>
|
||||
<Button onPress={clear} title="Clear" type="destructive" />
|
||||
<Button onPress={save} title="Save" type="primary" />
|
||||
</SideBySide>
|
||||
</Row>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export { OverrideSetScreen };
|
||||
64
packages/app/src/utils/data-context.tsx
Normal file
64
packages/app/src/utils/data-context.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useAsync, useAsyncCallback } from "#/features/async";
|
||||
import AsyncStorageLib from "@react-native-async-storage/async-storage";
|
||||
import { createContext, ReactNode, useState } from "react"
|
||||
|
||||
type DataContextOptions<T> = {
|
||||
createDefault: () => T;
|
||||
deserialize?: (item: T) => T;
|
||||
};
|
||||
|
||||
type DataContextProviderProps = {
|
||||
storageKey: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function createDataContext<T extends {[name: string]: any}>({
|
||||
createDefault,
|
||||
deserialize = a => a,
|
||||
}: DataContextOptions<T>) {
|
||||
const Context = createContext<{
|
||||
data: T;
|
||||
setData: (data: T | ((current: T) => T)) => Promise<void>;
|
||||
}>(undefined as any);
|
||||
|
||||
const Provider: React.FC<DataContextProviderProps> = ({
|
||||
storageKey: key,
|
||||
children,
|
||||
}) => {
|
||||
const [current, setCurrent] = useState<T>();
|
||||
|
||||
const [setData] = useAsyncCallback(
|
||||
async (input: T | ((current: T) => T)) => {
|
||||
let next = typeof input === 'function'
|
||||
? input(current!)
|
||||
: input;
|
||||
setCurrent(next);
|
||||
await AsyncStorageLib.setItem(key, JSON.stringify(next));
|
||||
},
|
||||
[key, current, setCurrent],
|
||||
);
|
||||
|
||||
useAsync(
|
||||
async () => {
|
||||
const raw = await AsyncStorageLib.getItem(key);
|
||||
const next = raw ? deserialize(JSON.parse(raw)) : createDefault();
|
||||
setCurrent(next);
|
||||
},
|
||||
[key, setCurrent],
|
||||
)
|
||||
|
||||
if (!current) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Context.Provider value={{ data: current, setData }}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
)
|
||||
};
|
||||
|
||||
return { Context, Provider };
|
||||
}
|
||||
|
||||
export { createDataContext };
|
||||
12
packages/app/tsconfig.json
Normal file
12
packages/app/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"#/*": ["./src/*"],
|
||||
"@morten-olsen/ui/*": ["../ui/src/*"],
|
||||
"@morten-olsen/ui": ["../ui/src/index.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
35
packages/app/types.tsx
Normal file
35
packages/app/types.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Learn more about using TypeScript with React Navigation:
|
||||
* https://reactnavigation.org/docs/typescript/
|
||||
*/
|
||||
|
||||
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
declare global {
|
||||
namespace ReactNavigation {
|
||||
interface RootParamList extends RootStackParamList {}
|
||||
}
|
||||
}
|
||||
|
||||
export type RootStackParamList = {
|
||||
Root: NavigatorScreenParams<RootTabParamList> | undefined;
|
||||
Modal: undefined;
|
||||
NotFound: undefined;
|
||||
};
|
||||
|
||||
export type RootStackScreenProps<Screen extends keyof RootStackParamList> = NativeStackScreenProps<
|
||||
RootStackParamList,
|
||||
Screen
|
||||
>;
|
||||
|
||||
export type RootTabParamList = {
|
||||
TabOne: undefined;
|
||||
TabTwo: undefined;
|
||||
};
|
||||
|
||||
export type RootTabScreenProps<Screen extends keyof RootTabParamList> = CompositeScreenProps<
|
||||
BottomTabScreenProps<RootTabParamList, Screen>,
|
||||
NativeStackScreenProps<RootStackParamList>
|
||||
>;
|
||||
26
packages/app/webpack.config.js
Normal file
26
packages/app/webpack.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const path = require("path");
|
||||
const createExpoWebpackConfigAsync = require("@expo/webpack-config");
|
||||
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
|
||||
|
||||
module.exports = async function (env, argv) {
|
||||
const config = await createExpoWebpackConfigAsync(env, argv);
|
||||
|
||||
// Use the React refresh plugin in development mode
|
||||
if (env.mode === "development") {
|
||||
config.plugins.push(
|
||||
new ReactRefreshWebpackPlugin({
|
||||
forceEnable: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.tsx?$/,
|
||||
loader: require.resolve("babel-loader"),
|
||||
include: [
|
||||
/@morten-olsen\/ui/,
|
||||
],
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
||||
Reference in New Issue
Block a user