Kin: personal relationships app — server (cadence/due/migrations) + Expo Android app
Some checks failed
Build & Release APK / build (push) Failing after 13s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:49:45 -07:00
commit ac0b82e6a0
101 changed files with 11534 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
name: Build & Release APK
on:
push:
branches: [main]
jobs:
build:
# Desktop runner only — RN/Gradle builds starve the 4-core VPS.
runs-on: desktop
container:
image: eclipse-temurin:21-jdk
steps:
- name: Checkout
# Hand-rolled: actions/checkout needs Node, the JDK image has none.
run: |
apt-get update -qq && apt-get install -y -qq git unzip curl > /dev/null
git init .
git fetch --depth 1 https://git.rehbock.xyz/${{ github.repository }}.git ${{ github.sha }}
git checkout FETCH_HEAD
- name: Install Node.js
run: |
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null
apt-get install -y -qq nodejs > /dev/null
node --version
- name: Install Android SDK
run: |
if [ ! -d "$HOME/android-sdk/platforms/android-36" ]; then
mkdir -p "$HOME/android-sdk/cmdline-tools"
curl -fsSL -o /tmp/clt.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/clt.zip -d "$HOME/android-sdk/cmdline-tools"
mv "$HOME/android-sdk/cmdline-tools/cmdline-tools" "$HOME/android-sdk/cmdline-tools/latest"
yes | "$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true
"$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" "platforms;android-36" "build-tools;36.0.0" > /dev/null
fi
- name: Install app deps
run: cd app && npm ci --no-audit --no-fund
- name: Decode signing keystore
run: echo "${{ secrets.KEYSTORE_B64 }}" | base64 -d > /tmp/release.jks
- name: Build signed release APK
env:
KEYSTORE_FILE: /tmp/release.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: kin
VERSION_CODE: ${{ github.run_number }}
VERSION_NAME: 1.${{ github.run_number }}
run: |
export ANDROID_HOME="$HOME/android-sdk"
cd app/android
echo "sdk.dir=$ANDROID_HOME" > local.properties
./gradlew assembleRelease --no-daemon --console=plain
- name: Publish Gitea release with APK
run: |
TAG="v1.${{ github.run_number }}"
API="https://git.rehbock.xyz/api/v1/repos/${{ github.repository }}"
AUTH="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
RELEASE=$(curl -fsS -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"target_commitish\":\"${{ github.sha }}\"}")
RID=$(echo "$RELEASE" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
curl -fsS -X POST "$API/releases/$RID/assets?name=kin-$TAG.apk" -H "$AUTH" \
-F "attachment=@app/android/app/build/outputs/apk/release/app-release.apk" > /dev/null
echo "Released $TAG"

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# server
server/node_modules/
server/.env
server/.env.token.local
server/bun.lock
# app
app/node_modules/
app/.expo/
app/dist/
app/android/app/build/
app/android/build/
app/android/.gradle/
app/android/local.properties
app/android/.kotlin/
app/ios/
app/expo-env.d.ts

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# Kin — personal relationships app
A self-hosted "relational wealth" tracker: who matters, when you last talked,
and who's due for a catch-up. Monica-style personal CRM, but thin: all data
lives in one Postgres database on your own server; the mobile app and web UI
are both clients of the same tiny API.
- **`server/`** — Bun + Hono API and vanilla-JS web UI, deployed as the `crm`
container at https://crm.rehbock.xyz (data in the shared `personal-db`
Postgres, database `personal`).
- **`app/`** — Expo / React Native app ("Kin"), Android APK built by Gitea
Actions and installed via Obtainium. iOS builds from the same code.
- **`docs/SCOPE.md`** — product scope and architecture decisions.
## How it works
Each person can have a **cadence** (contact every N days). The server computes
`urgency = days_since_last_contact / cadence` for everyone; the app's home
screen sorts by it (overdue → coming up → on track) and schedules a local
daily notification ("3 people are due for a catch-up"). Logging an interaction
takes two taps and resets the clock. Snooze pushes someone off the list for a
week without pretending you talked.
## Auth
Two paths into the same API, split by Caddy (`~/personal/proxy/Caddyfile` on
the VPS):
- **Browser** → Caddy `basic_auth` (user `admin`), then proxied to the app.
- **Mobile app** → sends `Authorization: Bearer <API_TOKEN>`; Caddy lets
Bearer requests through and the server validates the token itself
(`API_TOKEN` in `server/.env` on the VPS; local copy in
`server/.env.token.local`, gitignored).
Anything on the Docker networks can hit the API unauthenticated — the port is
never published on the host. Don't change that property.
## Server deploy
The server lives at `~/personal/crm` on the VPS. Restart is NOT enough — the
image bakes the source in:
```sh
rsync -a --delete --exclude .env --exclude .env.token.local --exclude node_modules \
server/ rehbock.xyz:personal/crm/
ssh rehbock.xyz 'cd ~/personal/crm; and docker compose up -d --build'
```
Migrations in `server/migrations/*.sql` run automatically at container boot
(recorded in `schema_migrations`). Add a new numbered file; never edit an
applied one.
## App development
```sh
cd app
npm install
npx expo start # dev server; press a for Android
npx tsc --noEmit # typecheck
```
`app/android/` is committed (CI builds it directly; `expo prebuild` only when
native config changes). Release signing: `~/.android-keys/kin-release.jks`
(alias `kin`, password in `kin-release.password` next to it), or
`KEYSTORE_FILE`/`KEYSTORE_PASSWORD` env in CI.
## Release pipeline
Push to `main` → Gitea Actions (`.gitea/workflows/release.yml`) on the
**desktop runner** (label `desktop` — never the VPS) → signed APK →
Gitea release `v1.<run>` with asset `kin-v1.<run>.apk` → Obtainium picks it up
from the repo URL. Repo secrets: `KEYSTORE_B64`, `KEYSTORE_PASSWORD`.
Version code/name come from the CI run number; `app.json`'s version is unused
on Android.

View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}

42
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
example
# generated native folders (android/ is committed — CI builds it)
/ios

1
app/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
app/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

3
app/AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.

1
app/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

21
app/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

19
app/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# generated inline modules
app/src/main/java/inline/
# Bundle artifacts
*.jsbundle

View File

@@ -0,0 +1,194 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'com.marcus.kin'
defaultConfig {
applicationId 'com.marcus.kin'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode ((System.getenv("VERSION_CODE") ?: "1").toInteger())
versionName (System.getenv("VERSION_NAME") ?: "1.0-dev")
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
release {
// CI provides KEYSTORE_FILE/KEYSTORE_PASSWORD; local builds fall
// back to ~/.android-keys/. If neither exists, release builds are
// debug-signed (fine for emulator work, not installable over CI builds).
def ksPath = System.getenv("KEYSTORE_FILE") ?: "${System.properties['user.home']}/.android-keys/kin-release.jks"
def passFile = new File("${System.properties['user.home']}/.android-keys/kin-release.password")
def ksPass = System.getenv("KEYSTORE_PASSWORD") ?: (passFile.exists() ? passFile.text.trim() : null)
if (new File(ksPath).exists() && ksPass != null) {
storeFile file(ksPath)
storePassword ksPass
keyAlias System.getenv("KEY_ALIAS") ?: "kin"
keyPassword System.getenv("KEY_PASSWORD") ?: ksPass
}
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
signingConfig signingConfigs.release.storeFile ? signingConfigs.release : signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

Binary file not shown.

14
app/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -0,0 +1,32 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize|assetsPaths" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="kin"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,65 @@
package com.marcus.kin
import expo.modules.splashscreen.SplashScreenManager
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
// setTheme(R.style.AppTheme);
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
SplashScreenManager.registerOnActivity(this)
// @generated end expo-splashscreen
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}

View File

@@ -0,0 +1,45 @@
package com.marcus.kin
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ExpoReactHostFactory
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
ExpoReactHostFactory.getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
)
}
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -0,0 +1 @@
<resources/>

View File

@@ -0,0 +1,6 @@
<resources>
<color name="splashscreen_background">#14110F</color>
<color name="iconBackground">#14110F</color>
<color name="colorPrimary">#023c69</color>
<color name="activityBackground">#14110F</color>
</resources>

View File

@@ -0,0 +1,5 @@
<resources>
<string name="app_name">Kin</string>
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
</resources>

View File

@@ -0,0 +1,15 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@color/activityBackground</item>
</style>
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
</style>
</resources>

24
app/android/build.gradle Normal file
View File

@@ -0,0 +1,24 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"

View File

@@ -0,0 +1,63 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx8192m -XX:MaxMetaspaceSize=1024m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=arm64-v8a
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
expo.inlineModules.watchedDirectories=[]

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
app/android/gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

98
app/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,98 @@
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
@REM
@REM This source code is licensed under the MIT license found in the
@REM LICENSE file in the root directory of this source tree.
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,39 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'Kin'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)

41
app/app.json Normal file
View File

@@ -0,0 +1,41 @@
{
"expo": {
"name": "Kin",
"slug": "kin",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "kin",
"userInterfaceStyle": "dark",
"backgroundColor": "#14110F",
"android": {
"package": "com.marcus.kin",
"adaptiveIcon": {
"backgroundColor": "#14110F",
"foregroundImage": "./assets/images/adaptive-icon.png",
"monochromeImage": "./assets/images/adaptive-icon.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#14110F",
"image": "./assets/images/splash-icon.png",
"imageWidth": 96
}
],
"expo-notifications"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
app/assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

7763
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
app/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "kin",
"main": "expo-router/entry",
"version": "1.0.0",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~57.0.11",
"expo-constants": "~57.0.9",
"expo-haptics": "~57.0.1",
"expo-linking": "~57.0.5",
"expo-notifications": "~57.0.9",
"expo-router": "~57.0.11",
"expo-splash-screen": "~57.0.5",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.10.1"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"typecheck": "tsc --noEmit"
},
"private": true
}

39
app/src/app/_layout.tsx Normal file
View File

@@ -0,0 +1,39 @@
import React, { useEffect } from "react";
import { AppState } from "react-native";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { C } from "../lib/theme";
import { hydrate, refresh } from "../lib/store";
export default function RootLayout() {
useEffect(() => {
void hydrate();
const sub = AppState.addEventListener("change", (s) => {
if (s === "active") void refresh();
});
return () => sub.remove();
}, []);
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: C.bg }}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: C.bg },
headerTintColor: C.text,
headerTitleStyle: { fontWeight: "700" },
headerShadowVisible: false,
contentStyle: { backgroundColor: C.bg },
}}
>
<Stack.Screen name="index" options={{ title: "Kin" }} />
<Stack.Screen name="people" options={{ title: "People" }} />
<Stack.Screen name="person/[id]" options={{ title: "" }} />
<Stack.Screen name="log" options={{ title: "Log contact", presentation: "modal" }} />
<Stack.Screen name="edit" options={{ title: "Person", presentation: "modal" }} />
<Stack.Screen name="settings" options={{ title: "Settings", presentation: "modal" }} />
</Stack>
</GestureHandlerRootView>
);
}

107
app/src/app/edit.tsx Normal file
View File

@@ -0,0 +1,107 @@
import React, { useEffect, useState } from "react";
import { Alert, ScrollView, Text, View } from "react-native";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { C } from "../lib/theme";
import { api } from "../lib/api";
import { refresh } from "../lib/store";
import { Button, Chips, Field } from "../components/ui";
import { success } from "../lib/haptics";
import { CADENCES } from "../lib/types";
// Add (no id param) or edit (id param) a person.
export default function EditPerson() {
const { id } = useLocalSearchParams<{ id?: string }>();
const router = useRouter();
const editing = Boolean(id);
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [email, setEmail] = useState("");
const [location, setLocation] = useState("");
const [tags, setTags] = useState("");
const [notes, setNotes] = useState("");
const [cadence, setCadence] = useState<(typeof CADENCES)[number]>(
CADENCES.find((c) => c.days === 30)!
);
const [loaded, setLoaded] = useState(!editing);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!id) return;
api.person(id).then((p) => {
setName(p.full_name);
setPhone(p.phone ?? "");
setEmail(p.email ?? "");
setLocation(p.location ?? "");
setTags(p.tags.join(", "));
setNotes(p.notes ?? "");
setCadence(CADENCES.find((c) => c.days === p.cadence_days) ?? CADENCES[CADENCES.length - 1]);
setLoaded(true);
});
}, [id]);
async function save() {
if (saving) return;
const full_name = name.trim();
if (!full_name) {
Alert.alert("Name is required");
return;
}
setSaving(true);
const body = {
full_name,
phone: phone.trim() || null,
email: email.trim() || null,
location: location.trim() || null,
tags,
notes: notes.trim() || null,
cadence_days: cadence.days,
};
try {
if (id) await api.updatePerson(id, body);
else await api.createPerson(body);
success();
void refresh();
router.back();
} catch (e) {
Alert.alert("Couldn't save", e instanceof Error ? e.message : "unknown error");
setSaving(false);
}
}
if (!loaded) {
return (
<View style={{ flex: 1, backgroundColor: C.bg, alignItems: "center", justifyContent: "center" }}>
<Text style={{ color: C.muted }}>Loading</Text>
</View>
);
}
return (
<>
<Stack.Screen options={{ title: editing ? "Edit person" : "Add person" }} />
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 60 }}
keyboardShouldPersistTaps="handled"
>
<Field label="Name" value={name} onChangeText={setName} placeholder="Full name" autoFocus={!editing} />
<Field label="Phone" value={phone} onChangeText={setPhone} placeholder="+61…" keyboardType="phone-pad" />
<Field label="Email" value={email} onChangeText={setEmail} placeholder="them@example.com" keyboardType="email-address" autoCapitalize="none" />
<Field label="Location" value={location} onChangeText={setLocation} placeholder="Sydney" />
<Field label="Tags" value={tags} onChangeText={setTags} placeholder="family, sf, climbing" autoCapitalize="none" />
<Field label="Notes" value={notes} onChangeText={setNotes} placeholder="How you met, what matters to them…" multiline />
<Text style={{ color: C.muted, fontSize: 13, marginBottom: 8 }}>Stay in touch</Text>
<Chips
options={CADENCES}
value={cadence}
onChange={setCadence}
getLabel={(c) => c.label}
getKey={(c) => String(c.days)}
/>
<View style={{ height: 20 }} />
<Button title={saving ? "Saving…" : editing ? "Save" : "Add person"} onPress={() => void save()} />
</ScrollView>
</>
);
}

136
app/src/app/index.tsx Normal file
View File

@@ -0,0 +1,136 @@
import React, { useMemo } from "react";
import {
Pressable,
RefreshControl,
SectionList,
StyleSheet,
Text,
View,
} from "react-native";
import { useRouter } from "expo-router";
import { C } from "../lib/theme";
import { useStore } from "../lib/useStore";
import { refresh } from "../lib/store";
import { PersonRow } from "../components/PersonRow";
import { Empty } from "../components/ui";
import type { DuePerson } from "../lib/types";
// Home: the relational-wealth dashboard. Who's overdue, who's coming up.
export default function Today() {
const { due, people, loading, error, lastSync } = useStore();
const router = useRouter();
const sections = useMemo(() => {
const d = due ?? [];
const overdue = d.filter((p) => p.status === "overdue");
const soon = d.filter((p) => p.status === "due_soon");
const ok = d.filter((p) => p.status === "ok");
const snoozed = d.filter((p) => p.status === "snoozed");
const out: { title: string; data: DuePerson[] }[] = [];
if (overdue.length) out.push({ title: "Reach out", data: overdue });
if (soon.length) out.push({ title: "Coming up", data: soon });
if (ok.length) out.push({ title: "On track", data: ok });
if (snoozed.length) out.push({ title: "Snoozed", data: snoozed });
return out;
}, [due]);
const noCadences = due != null && due.length === 0;
return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
{error ? (
<Pressable onPress={() => router.push("/settings")} style={s.errorBar}>
<Text style={s.errorText}>{error} tap for Settings</Text>
</Pressable>
) : null}
<SectionList
sections={sections}
keyExtractor={(p) => p.id}
renderItem={({ item }) => <PersonRow person={item} due={item} />}
renderSectionHeader={({ section }) => (
<Text style={s.sectionHeader}>{section.title}</Text>
)}
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={() => void refresh()}
tintColor={C.muted}
/>
}
ListEmptyComponent={
noCadences ? (
<Empty
title={people?.length ? "No cadences set yet" : "Welcome to Kin"}
hint={
people?.length
? "Open People and give each person a cadence — how often you want to be in touch. They'll show up here when it's time."
: "Set your server token in Settings, then add the people who matter and how often you want to reach out."
}
/>
) : due == null ? (
<Empty title="Loading…" hint={error ?? undefined} />
) : null
}
contentContainerStyle={{ paddingBottom: 96 }}
/>
<View style={s.bottomBar}>
<Pressable onPress={() => router.push("/people")} style={s.bottomItem}>
<Text style={s.bottomText}>People</Text>
</Pressable>
<Pressable
onPress={() => router.push("/edit")}
style={[s.bottomItem, s.addBtn]}
>
<Text style={s.addText}></Text>
</Pressable>
<Pressable onPress={() => router.push("/settings")} style={s.bottomItem}>
<Text style={s.bottomText}>Settings</Text>
</Pressable>
</View>
{lastSync == null && !error ? null : null}
</View>
);
}
const s = StyleSheet.create({
sectionHeader: {
color: C.muted,
fontSize: 12,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 1,
backgroundColor: C.bg,
paddingHorizontal: 16,
paddingTop: 18,
paddingBottom: 6,
},
errorBar: { backgroundColor: "#3A2224", paddingVertical: 8, paddingHorizontal: 16 },
errorText: { color: C.danger, fontSize: 13 },
bottomBar: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-around",
backgroundColor: C.surface,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: C.border,
paddingVertical: 10,
paddingBottom: 22,
},
bottomItem: { paddingHorizontal: 20, paddingVertical: 6 },
bottomText: { color: C.text, fontSize: 15, fontWeight: "600" },
addBtn: {
backgroundColor: C.accent,
borderRadius: 24,
width: 48,
height: 48,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 0,
paddingVertical: 0,
},
addText: { color: "#1A0E10", fontSize: 24, fontWeight: "700", marginTop: -2 },
});

77
app/src/app/log.tsx Normal file
View File

@@ -0,0 +1,77 @@
import React, { useState } from "react";
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { C } from "../lib/theme";
import { api } from "../lib/api";
import { refresh } from "../lib/store";
import { Button, Chips, Field } from "../components/ui";
import { success } from "../lib/haptics";
import { INTERACTION_TYPES } from "../lib/types";
// Quick-log modal: two taps to record "I texted Alice today".
export default function LogInteraction() {
const { personId, name } = useLocalSearchParams<{ personId: string; name?: string }>();
const router = useRouter();
const [type, setType] = useState<string>("text");
const [when, setWhen] = useState<"today" | "yesterday">("today");
const [summary, setSummary] = useState("");
const [saving, setSaving] = useState(false);
async function save() {
if (!personId || saving) return;
setSaving(true);
try {
const occurred =
when === "today" ? undefined : new Date(Date.now() - 86400_000).toISOString();
await api.logInteraction(personId, {
type,
occurred_at: occurred,
summary: summary.trim() || undefined,
});
success();
void refresh();
router.back();
} catch (e) {
Alert.alert("Couldn't save", e instanceof Error ? e.message : "unknown error");
setSaving(false);
}
}
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={s.body}>
{name ? <Text style={s.who}>{name}</Text> : null}
<Text style={s.label}>What was it?</Text>
<Chips
options={[...INTERACTION_TYPES]}
value={type}
onChange={setType}
getLabel={(t) => t}
getKey={(t) => t}
/>
<View style={{ height: 18 }} />
<Text style={s.label}>When?</Text>
<Chips
options={["today", "yesterday"] as const}
value={when}
onChange={(w) => setWhen(w)}
getLabel={(w) => w}
getKey={(w) => w}
/>
<View style={{ height: 18 }} />
<Field
label="Note (optional)"
value={summary}
onChangeText={setSummary}
placeholder="What did you talk about?"
multiline
/>
<Button title={saving ? "Saving…" : "Log it"} onPress={() => void save()} />
</ScrollView>
);
}
const s = StyleSheet.create({
body: { padding: 16, paddingBottom: 40 },
who: { color: C.text, fontSize: 20, fontWeight: "700", marginBottom: 16 },
label: { color: C.muted, fontSize: 13, marginBottom: 8 },
});

122
app/src/app/people.tsx Normal file
View File

@@ -0,0 +1,122 @@
import React, { useMemo, useState } from "react";
import {
FlatList,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { C } from "../lib/theme";
import { useStore } from "../lib/useStore";
import { refresh } from "../lib/store";
import { PersonRow } from "../components/PersonRow";
import { Empty } from "../components/ui";
import { tap } from "../lib/haptics";
// Full contact list. Search + tag filter are client-side — the whole network
// fits in memory many times over.
export default function People() {
const { people, loading } = useStore();
const [q, setQ] = useState("");
const [tag, setTag] = useState<string | null>(null);
const tags = useMemo(() => {
const counts = new Map<string, number>();
for (const p of people ?? [])
for (const t of p.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t);
}, [people]);
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase();
return (people ?? []).filter((p) => {
if (tag && !p.tags.includes(tag)) return false;
if (!needle) return true;
return [p.full_name, p.email, p.phone, p.location]
.some((f) => f?.toLowerCase().includes(needle));
});
}, [people, q, tag]);
return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
<TextInput
value={q}
onChangeText={setQ}
placeholder="Search people…"
placeholderTextColor={C.muted}
style={s.search}
autoCorrect={false}
/>
{tags.length ? (
<View style={s.tagRow}>
<FlatList
horizontal
data={tags}
keyExtractor={(t) => t}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 12, gap: 8 }}
renderItem={({ item }) => {
const active = item === tag;
return (
<Pressable
onPress={() => {
tap();
setTag(active ? null : item);
}}
style={[s.tagChip, active && { backgroundColor: C.accent, borderColor: C.accent }]}
>
<Text style={[s.tagChipText, active && { color: "#1A0E10", fontWeight: "700" }]}>
{item}
</Text>
</Pressable>
);
}}
/>
</View>
) : null}
<FlatList
data={filtered}
keyExtractor={(p) => p.id}
renderItem={({ item }) => <PersonRow person={item} />}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={() => void refresh()} tintColor={C.muted} />
}
ListEmptyComponent={
<Empty
title={people == null ? "Loading…" : "Nobody here"}
hint={people == null ? undefined : "Add people from the home screen."}
/>
}
contentContainerStyle={{ paddingBottom: 40 }}
/>
</View>
);
}
const s = StyleSheet.create({
search: {
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
borderRadius: 10,
marginHorizontal: 12,
marginTop: 10,
marginBottom: 8,
paddingHorizontal: 12,
paddingVertical: 9,
color: C.text,
fontSize: 15,
},
tagRow: { marginBottom: 6 },
tagChip: {
borderRadius: 14,
borderWidth: 1,
borderColor: C.border,
backgroundColor: C.surface,
paddingHorizontal: 11,
paddingVertical: 5,
},
tagChipText: { color: C.text, fontSize: 12 },
});

304
app/src/app/person/[id].tsx Normal file
View File

@@ -0,0 +1,304 @@
import React, { useCallback, useState } from "react";
import {
Alert,
Linking,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
import { C, STATUS_COLOR } from "../../lib/theme";
import { api } from "../../lib/api";
import { refresh } from "../../lib/store";
import { ago, cadenceLabel } from "../../lib/format";
import { Button, Chips, TagPill } from "../../components/ui";
import { tap, success } from "../../lib/haptics";
import { CADENCES, type PersonDetail } from "../../lib/types";
export default function Person() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [person, setPerson] = useState<PersonDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
if (!id) return;
api
.person(id)
.then((p) => {
setPerson(p);
setError(null);
})
.catch((e) => setError(e instanceof Error ? e.message : "failed to load"));
}, [id]);
useFocusEffect(
useCallback(() => {
load();
}, [load])
);
if (!person) {
return (
<View style={s.center}>
<Text style={{ color: error ? C.danger : C.muted }}>{error ?? "Loading…"}</Text>
</View>
);
}
const contactActions: { label: string; url: string }[] = [];
if (person.phone) {
const tel = person.phone.replace(/[^+\d]/g, "");
contactActions.push({ label: "Message", url: `sms:${tel}` });
contactActions.push({ label: "Call", url: `tel:${tel}` });
contactActions.push({ label: "WhatsApp", url: `https://wa.me/${tel.replace("+", "")}` });
}
if (person.email) contactActions.push({ label: "Email", url: `mailto:${person.email}` });
async function setCadence(days: number | null) {
try {
await api.updatePerson(person!.id, { cadence_days: days });
load();
void refresh();
} catch (e) {
Alert.alert("Couldn't update", e instanceof Error ? e.message : "unknown error");
}
}
async function snooze() {
try {
await api.snooze(person!.id, 7);
success();
load();
void refresh();
} catch (e) {
Alert.alert("Couldn't snooze", e instanceof Error ? e.message : "unknown error");
}
}
function confirmArchive() {
Alert.alert(
person!.archived ? "Unarchive?" : "Archive?",
person!.archived
? "They'll reappear in lists and reminders."
: "Hidden from lists and reminders. History is kept.",
[
{ text: "Cancel", style: "cancel" },
{
text: person!.archived ? "Unarchive" : "Archive",
style: "destructive",
onPress: async () => {
await api.updatePerson(person!.id, { archived: !person!.archived });
void refresh();
router.back();
},
},
]
);
}
const currentCadence = CADENCES.find((c) => c.days === person.cadence_days) ?? null;
return (
<>
<Stack.Screen
options={{
title: person.full_name,
headerRight: () => (
<Pressable
onPress={() => {
tap();
router.push({ pathname: "/edit", params: { id: person.id } });
}}
hitSlop={8}
>
<Text style={{ color: C.accent, fontSize: 15, fontWeight: "600" }}>Edit</Text>
</Pressable>
),
}}
/>
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={s.body}>
{person.tags.length || person.location ? (
<View style={s.metaRow}>
{person.location ? <Text style={s.location}>{person.location}</Text> : null}
{person.tags.map((t) => (
<TagPill key={t} tag={t} />
))}
</View>
) : null}
<Text style={s.lastContact}>
Last contact:{" "}
<Text style={{ color: C.text }}>{ago(person.last_contacted ?? (person.interactions[0]?.occurred_at ?? null))}</Text>
{" · "}
{person.interactions.length} logged
</Text>
{contactActions.length ? (
<View style={s.actionRow}>
{contactActions.map((a) => (
<Pressable
key={a.label}
onPress={() => {
tap();
void Linking.openURL(a.url).catch(() => {});
}}
style={s.actionBtn}
>
<Text style={s.actionText}>{a.label}</Text>
</Pressable>
))}
</View>
) : null}
<View style={s.card}>
<Text style={s.cardLabel}>Stay in touch</Text>
<Chips
options={CADENCES}
value={currentCadence}
onChange={(c) => void setCadence(c.days)}
getLabel={(c) => c.label}
getKey={(c) => String(c.days)}
/>
{person.snoozed_until ? (
<Text style={s.snoozedNote}>Snoozed until {String(person.snoozed_until).slice(0, 10)}</Text>
) : null}
</View>
<View style={{ flexDirection: "row", gap: 10, marginBottom: 18 }}>
<View style={{ flex: 1 }}>
<Button
title="Log contact"
onPress={() =>
router.push({
pathname: "/log",
params: { personId: person.id, name: person.full_name },
})
}
/>
</View>
<View style={{ flex: 1 }}>
<Button title="Snooze 7d" kind="ghost" onPress={() => void snooze()} />
</View>
</View>
{person.notes ? (
<View style={s.card}>
<Text style={s.cardLabel}>Notes</Text>
<Text style={s.notes}>{person.notes}</Text>
</View>
) : null}
{person.relationships.length ? (
<View style={s.card}>
<Text style={s.cardLabel}>Relationships</Text>
{person.relationships.map((r) => (
<Pressable
key={r.id}
onPress={() =>
router.push({ pathname: "/person/[id]", params: { id: r.other_id } })
}
style={s.relRow}
>
<Text style={s.relName}>{r.other_name}</Text>
<Text style={s.relType}>{r.type}</Text>
</Pressable>
))}
</View>
) : null}
<View style={s.card}>
<Text style={s.cardLabel}>History</Text>
{person.interactions.length === 0 ? (
<Text style={s.notes}>Nothing logged yet.</Text>
) : (
person.interactions.map((i) => (
<Pressable
key={i.id}
onLongPress={() => {
Alert.alert("Delete this entry?", i.summary ?? i.type ?? "", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
await api.deleteInteraction(i.id);
load();
void refresh();
},
},
]);
}}
style={s.histRow}
>
<Text style={s.histDate}>{String(i.occurred_at).slice(0, 10)}</Text>
<View style={{ flex: 1 }}>
<Text style={s.histType}>{i.type ?? "contact"}</Text>
{i.summary ? <Text style={s.histSummary}>{i.summary}</Text> : null}
</View>
</Pressable>
))
)}
</View>
<Button
title={person.archived ? "Unarchive" : "Archive"}
kind="danger"
onPress={confirmArchive}
/>
<View style={{ height: 40 }} />
</ScrollView>
</>
);
}
const s = StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: C.bg },
body: { padding: 16 },
metaRow: { flexDirection: "row", flexWrap: "wrap", gap: 6, alignItems: "center", marginBottom: 10 },
location: { color: C.muted, fontSize: 13, marginRight: 4 },
lastContact: { color: C.muted, fontSize: 14, marginBottom: 14 },
actionRow: { flexDirection: "row", gap: 8, marginBottom: 18, flexWrap: "wrap" },
actionBtn: {
backgroundColor: C.surface2,
borderWidth: 1,
borderColor: C.border,
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 9,
},
actionText: { color: C.accent, fontSize: 14, fontWeight: "600" },
card: {
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
borderRadius: 12,
padding: 14,
marginBottom: 18,
},
cardLabel: {
color: C.muted,
fontSize: 12,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 10,
},
snoozedNote: { color: C.warn, fontSize: 13, marginTop: 10 },
notes: { color: C.text, fontSize: 14, lineHeight: 20 },
relRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
},
relName: { color: C.text, fontSize: 14, fontWeight: "600" },
relType: { color: C.muted, fontSize: 13 },
histRow: { flexDirection: "row", gap: 12, paddingVertical: 8 },
histDate: { color: C.muted, fontSize: 13, width: 84 },
histType: { color: C.text, fontSize: 14, fontWeight: "600" },
histSummary: { color: C.muted, fontSize: 13, marginTop: 2 },
});

85
app/src/app/settings.tsx Normal file
View File

@@ -0,0 +1,85 @@
import React, { useEffect, useState } from "react";
import { Alert, ScrollView, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { C } from "../lib/theme";
import { DEFAULT_URL, getConfig, setConfig } from "../lib/config";
import { api } from "../lib/api";
import { refresh } from "../lib/store";
import { Button, Chips, Field } from "../components/ui";
const HOURS = [8, 9, 10, 12, 18, 20];
export default function Settings() {
const router = useRouter();
const [url, setUrl] = useState(DEFAULT_URL);
const [token, setToken] = useState("");
const [hour, setHour] = useState(9);
const [busy, setBusy] = useState(false);
useEffect(() => {
void getConfig().then((c) => {
setUrl(c.url);
setToken(c.token);
setHour(c.notifHour);
});
}, []);
async function save() {
if (busy) return;
setBusy(true);
await setConfig(url, token, hour);
try {
await api.due(); // probe: fails fast on bad URL/token
await refresh();
router.back();
} catch (e) {
Alert.alert(
"Couldn't reach the server",
e instanceof Error ? e.message : "unknown error"
);
setBusy(false);
}
}
return (
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={{ padding: 16 }}
keyboardShouldPersistTaps="handled"
>
<Field
label="Server URL"
value={url}
onChangeText={setUrl}
autoCapitalize="none"
autoCorrect={false}
placeholder={DEFAULT_URL}
/>
<Field
label="API token"
value={token}
onChangeText={setToken}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
placeholder="paste the bearer token"
/>
<Text style={{ color: C.muted, fontSize: 13, marginBottom: 8 }}>
Daily reminder time
</Text>
<Chips
options={HOURS}
value={hour}
onChange={setHour}
getLabel={(h) => `${h}:00`}
getKey={(h) => String(h)}
/>
<View style={{ height: 20 }} />
<Button title={busy ? "Checking…" : "Save"} onPress={() => void save()} />
<Text style={{ color: C.muted, fontSize: 12, marginTop: 24, lineHeight: 18 }}>
Kin is a thin client over your own server all data lives in your
Postgres database. The token is stored only on this device.
</Text>
</ScrollView>
);
}

View File

@@ -0,0 +1,78 @@
import React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { C, STATUS_COLOR } from "../lib/theme";
import { ago, cadenceLabel, dueLine } from "../lib/format";
import { tap } from "../lib/haptics";
import type { DuePerson, Person } from "../lib/types";
// One row, used by both the Today (due) list and the People list.
// `due` rows show overdue info + a quick-log button.
export function PersonRow({ person, due }: { person: Person; due?: DuePerson }) {
const router = useRouter();
const subtitle = due
? dueLine(due.days_since, due.cadence_days ?? 0, due.last_contacted)
: `${ago(person.last_contacted)} · ${cadenceLabel(person.cadence_days)}`;
return (
<Pressable
onPress={() => {
tap();
router.push({ pathname: "/person/[id]", params: { id: person.id } });
}}
style={({ pressed }) => [s.row, pressed && { backgroundColor: C.surface2 }]}
>
{due ? <View style={[s.dot, { backgroundColor: STATUS_COLOR[due.status] }]} /> : null}
<View style={{ flex: 1 }}>
<Text style={s.name} numberOfLines={1}>
{person.full_name}
</Text>
<Text style={s.sub} numberOfLines={1}>
{subtitle}
</Text>
</View>
{due ? (
<Pressable
onPress={(e) => {
e.stopPropagation();
tap();
router.push({
pathname: "/log",
params: { personId: person.id, name: person.full_name },
});
}}
style={({ pressed }) => [s.logBtn, pressed && { opacity: 0.6 }]}
hitSlop={8}
>
<Text style={s.logBtnText}></Text>
</Pressable>
) : null}
</Pressable>
);
}
const s = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
},
dot: { width: 8, height: 8, borderRadius: 4 },
name: { color: C.text, fontSize: 16, fontWeight: "600" },
sub: { color: C.muted, fontSize: 13, marginTop: 2 },
logBtn: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: C.surface2,
borderWidth: 1,
borderColor: C.border,
alignItems: "center",
justifyContent: "center",
},
logBtnText: { color: C.accent, fontSize: 16, fontWeight: "700" },
});

164
app/src/components/ui.tsx Normal file
View File

@@ -0,0 +1,164 @@
import React from "react";
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
type TextInputProps,
} from "react-native";
import { C } from "../lib/theme";
import { tap } from "../lib/haptics";
export function Button({
title,
onPress,
kind = "primary",
small,
}: {
title: string;
onPress: () => void;
kind?: "primary" | "ghost" | "danger";
small?: boolean;
}) {
return (
<Pressable
onPress={() => {
tap();
onPress();
}}
style={({ pressed }) => [
s.btn,
small && s.btnSmall,
kind === "primary" && { backgroundColor: C.accent },
kind === "ghost" && { backgroundColor: C.surface2, borderWidth: 1, borderColor: C.border },
kind === "danger" && { backgroundColor: "transparent", borderWidth: 1, borderColor: C.danger },
pressed && { opacity: 0.7 },
]}
>
<Text
style={[
s.btnText,
small && { fontSize: 13 },
kind === "primary" && { color: "#1A0E10", fontWeight: "700" },
kind === "ghost" && { color: C.text },
kind === "danger" && { color: C.danger },
]}
>
{title}
</Text>
</Pressable>
);
}
export function Field({
label,
...props
}: TextInputProps & { label: string }) {
return (
<View style={{ marginBottom: 14 }}>
<Text style={s.label}>{label}</Text>
<TextInput
placeholderTextColor={C.muted}
style={[s.input, props.multiline && { minHeight: 80, textAlignVertical: "top" }]}
{...props}
/>
</View>
);
}
export function Chips<T>({
options,
value,
onChange,
getLabel,
getKey,
}: {
options: T[];
value: T | null;
onChange: (v: T) => void;
getLabel: (v: T) => string;
getKey: (v: T) => string;
}) {
return (
<View style={s.chips}>
{options.map((o) => {
const active = value != null && getKey(o) === getKey(value);
return (
<Pressable
key={getKey(o)}
onPress={() => {
tap();
onChange(o);
}}
style={[s.chip, active && { backgroundColor: C.accent, borderColor: C.accent }]}
>
<Text style={[s.chipText, active && { color: "#1A0E10", fontWeight: "700" }]}>
{getLabel(o)}
</Text>
</Pressable>
);
})}
</View>
);
}
export function TagPill({ tag }: { tag: string }) {
return (
<View style={s.tag}>
<Text style={s.tagText}>{tag}</Text>
</View>
);
}
export function Empty({ title, hint }: { title: string; hint?: string }) {
return (
<View style={s.empty}>
<Text style={s.emptyTitle}>{title}</Text>
{hint ? <Text style={s.emptyHint}>{hint}</Text> : null}
</View>
);
}
const s = StyleSheet.create({
btn: {
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 18,
alignItems: "center",
justifyContent: "center",
},
btnSmall: { paddingVertical: 7, paddingHorizontal: 12 },
btnText: { fontSize: 15, fontWeight: "600" },
label: { color: C.muted, fontSize: 13, marginBottom: 6 },
input: {
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
color: C.text,
fontSize: 15,
},
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: {
borderRadius: 16,
borderWidth: 1,
borderColor: C.border,
backgroundColor: C.surface,
paddingHorizontal: 12,
paddingVertical: 6,
},
chipText: { color: C.text, fontSize: 13 },
tag: {
backgroundColor: C.surface2,
borderRadius: 10,
paddingHorizontal: 8,
paddingVertical: 2,
},
tagText: { color: C.muted, fontSize: 12 },
empty: { alignItems: "center", paddingVertical: 48, paddingHorizontal: 24 },
emptyTitle: { color: C.text, fontSize: 16, fontWeight: "600", marginBottom: 6 },
emptyHint: { color: C.muted, fontSize: 14, textAlign: "center", lineHeight: 20 },
});

61
app/src/lib/api.ts Normal file
View File

@@ -0,0 +1,61 @@
import { getConfig } from "./config";
import type { DuePerson, Interaction, Person, PersonDetail } from "./types";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const { url, token } = await getConfig();
if (!token) throw new ApiError(401, "No API token set — open Settings");
const res = await fetch(`${url}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...init?.headers,
},
});
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
const body = (await res.json()) as { error?: string };
if (body.error) msg = body.error;
} catch {}
throw new ApiError(res.status, msg);
}
return (await res.json()) as T;
}
// The server normalizes `tags` from either an array or a comma-separated string.
export type PersonInput = Partial<Omit<Person, "tags"> & { tags: string[] | string }>;
export const api = {
due: () => call<DuePerson[]>("/due"),
people: () => call<Person[]>("/people?sort=stale"),
person: (id: string) => call<PersonDetail>(`/people/${id}`),
createPerson: (body: PersonInput) =>
call<Person>("/people", { method: "POST", body: JSON.stringify(body) }),
updatePerson: (id: string, body: PersonInput) =>
call<Person>(`/people/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deletePerson: (id: string) => call<{ deleted: string }>(`/people/${id}`, { method: "DELETE" }),
logInteraction: (
personId: string,
body: { type?: string; occurred_at?: string; summary?: string; notes?: string }
) =>
call<Interaction>(`/people/${personId}/interactions`, {
method: "POST",
body: JSON.stringify(body),
}),
deleteInteraction: (id: string) =>
call<{ deleted: string }>(`/interactions/${id}`, { method: "DELETE" }),
snooze: (personId: string, days: number) =>
call<{ id: string; snoozed_until: string | null }>(`/people/${personId}/snooze`, {
method: "POST",
body: JSON.stringify({ days }),
}),
};

32
app/src/lib/config.ts Normal file
View File

@@ -0,0 +1,32 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
export const DEFAULT_URL = "https://crm.rehbock.xyz/api";
const KEYS = { url: "kin_api_url", token: "kin_api_token", hour: "kin_notif_hour" };
let cached: { url: string; token: string; notifHour: number } | null = null;
export async function getConfig() {
if (cached) return cached;
const [url, token, hour] = await Promise.all([
AsyncStorage.getItem(KEYS.url),
AsyncStorage.getItem(KEYS.token),
AsyncStorage.getItem(KEYS.hour),
]);
cached = {
url: url || DEFAULT_URL,
token: token || "",
notifHour: hour ? Number(hour) : 9,
};
return cached;
}
export async function setConfig(url: string, token: string, notifHour: number) {
const cleanUrl = (url.trim() || DEFAULT_URL).replace(/\/+$/, "");
cached = { url: cleanUrl, token: token.trim(), notifHour };
await Promise.all([
AsyncStorage.setItem(KEYS.url, cleanUrl),
AsyncStorage.setItem(KEYS.token, cached.token),
AsyncStorage.setItem(KEYS.hour, String(notifHour)),
]);
}

29
app/src/lib/format.ts Normal file
View File

@@ -0,0 +1,29 @@
import { CADENCES } from "./types";
// "3d ago", "2w ago", "5mo ago", "never"
export function ago(iso: string | null): string {
if (!iso) return "never";
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400_000);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 14) return `${days}d ago`;
if (days < 60) return `${Math.round(days / 7)}w ago`;
if (days < 365) return `${Math.round(days / 30)}mo ago`;
return `${Math.round((days / 365) * 10) / 10}y ago`;
}
export function cadenceLabel(days: number | null): string {
if (days == null) return "no cadence";
const preset = CADENCES.find((c) => c.days === days);
if (preset) return preset.label.toLowerCase();
return `every ${days}d`;
}
// Human line for the due list: how late someone is.
export function dueLine(daysSince: number, cadence: number, lastContacted: string | null): string {
const late = daysSince - cadence;
const base = lastContacted ? `last contact ${ago(lastContacted)}` : "never contacted";
if (late > 0) return `${base} · ${late}d overdue`;
if (late === 0) return `${base} · due today`;
return `${base} · due in ${-late}d`;
}

11
app/src/lib/haptics.ts Normal file
View File

@@ -0,0 +1,11 @@
import * as Haptics from "expo-haptics";
import { Platform } from "react-native";
export function tap() {
if (Platform.OS !== "web") void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
export function success() {
if (Platform.OS !== "web")
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}

View File

@@ -0,0 +1,82 @@
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
import { getConfig } from "./config";
import type { DuePerson } from "./types";
// Local daily digest, scheduled 7 days ahead from the latest due data every
// time we sync. No server push involved: each day at notifHour the phone
// shows how many people are due, computed from cadences known at sync time.
// If the app isn't opened for a week the notifications run out — which is
// itself a decent nudge to open the app.
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
let permissionAsked = false;
export async function ensurePermission(): Promise<boolean> {
if (Platform.OS === "web") return false;
const cur = await Notifications.getPermissionsAsync();
if (cur.granted) return true;
if (permissionAsked) return false;
permissionAsked = true;
const req = await Notifications.requestPermissionsAsync();
return req.granted;
}
// Number of people whose due date falls on or before `day`.
function dueCountOn(due: DuePerson[], day: Date): number {
const dayEnd = new Date(day);
dayEnd.setHours(23, 59, 59, 999);
return due.filter((p) => {
if (p.snoozed || p.cadence_days == null) return false;
const anchor = p.last_contacted ? new Date(p.last_contacted) : null;
if (!anchor) return true; // never contacted → always due
const dueAt = new Date(anchor.getTime() + p.cadence_days * 86400_000);
return dueAt <= dayEnd;
}).length;
}
export async function scheduleDigest(due: DuePerson[]) {
if (Platform.OS === "web") return;
if (!(await ensurePermission())) return;
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("reminders", {
name: "Catch-up reminders",
importance: Notifications.AndroidImportance.DEFAULT,
});
}
await Notifications.cancelAllScheduledNotificationsAsync();
const { notifHour } = await getConfig();
const now = new Date();
for (let i = 0; i < 7; i++) {
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i, notifHour, 0, 0);
if (day <= now) continue;
const n = dueCountOn(due, day);
if (n === 0) continue;
const names = due
.filter((p) => !p.snoozed)
.slice(0, 3)
.map((p) => p.full_name.split(" ")[0])
.join(", ");
await Notifications.scheduleNotificationAsync({
content: {
title: n === 1 ? "1 person is due for a catch-up" : `${n} people are due for a catch-up`,
body: names ? `Start with ${names}` : "Open Kin to see who",
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: day,
channelId: "reminders",
},
});
}
}

69
app/src/lib/store.ts Normal file
View File

@@ -0,0 +1,69 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { api } from "./api";
import { scheduleDigest } from "./notifications";
import type { DuePerson, Person } from "./types";
// Read-cache store: renders instantly from AsyncStorage, refreshes from the
// API on focus/foreground. Writes go straight to the API (this app is a thin
// client over the VPS database) — screens call api.* then refresh().
export type State = {
due: DuePerson[] | null;
people: Person[] | null;
loading: boolean;
error: string | null;
lastSync: number | null;
};
let state: State = { due: null, people: null, loading: false, error: null, lastSync: null };
const listeners = new Set<() => void>();
const CACHE_KEY = "kin_cache_v1";
function emit(next: Partial<State>) {
state = { ...state, ...next };
for (const l of listeners) l();
}
export function getState() {
return state;
}
export function subscribe(l: () => void) {
listeners.add(l);
return () => {
listeners.delete(l);
};
}
let hydrated = false;
export async function hydrate() {
if (hydrated) return;
hydrated = true;
try {
const raw = await AsyncStorage.getItem(CACHE_KEY);
if (raw) {
const c = JSON.parse(raw) as Pick<State, "due" | "people" | "lastSync">;
emit({ due: c.due, people: c.people, lastSync: c.lastSync });
}
} catch {}
void refresh();
}
let inflight: Promise<void> | null = null;
export function refresh(): Promise<void> {
if (inflight) return inflight;
inflight = (async () => {
emit({ loading: true });
try {
const [due, people] = await Promise.all([api.due(), api.people()]);
emit({ due, people, error: null, loading: false, lastSync: Date.now() });
void AsyncStorage.setItem(CACHE_KEY, JSON.stringify({ due, people, lastSync: state.lastSync }));
void scheduleDigest(due);
} catch (e) {
emit({ loading: false, error: e instanceof Error ? e.message : "sync failed" });
} finally {
inflight = null;
}
})();
return inflight;
}

20
app/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,20 @@
// Warm dark palette. Single source of truth for colors, imported as C.
export const C = {
bg: "#14110F",
surface: "#1D1916",
surface2: "#272220",
border: "#332D29",
text: "#F2EDE7",
muted: "#9C9088",
accent: "#E8747C", // warm rose — this is a people app
good: "#58B387",
warn: "#E5B458",
danger: "#E06060",
} as const;
export const STATUS_COLOR: Record<string, string> = {
overdue: C.danger,
due_soon: C.warn,
ok: C.good,
snoozed: C.muted,
};

66
app/src/lib/types.ts Normal file
View File

@@ -0,0 +1,66 @@
export type Person = {
id: string;
full_name: string;
first_name?: string | null;
last_name?: string | null;
email: string | null;
phone: string | null;
tags: string[];
notes?: string | null;
location: string | null;
source?: string | null;
cadence_days: number | null;
snoozed_until: string | null;
archived: boolean;
birthday: string | null;
last_contacted: string | null;
interaction_count: number;
created_at?: string;
updated_at?: string;
};
export type DueStatus = "overdue" | "due_soon" | "ok" | "snoozed";
export type DuePerson = Person & {
days_since: number;
urgency: number;
due_in_days: number;
snoozed: boolean;
status: DueStatus;
last_type: string | null;
};
export type Interaction = {
id: string;
type: string | null;
occurred_at: string;
summary: string | null;
notes: string | null;
};
export type Relationship = {
id: string;
type: string;
notes: string | null;
other_id: string;
other_name: string;
outgoing: boolean;
};
export type PersonDetail = Person & {
interactions: Interaction[];
relationships: Relationship[];
};
export const INTERACTION_TYPES = ["text", "call", "video", "hangout", "email", "other"] as const;
// Cadence presets shown in the picker. Label → days.
export const CADENCES: { label: string; days: number | null }[] = [
{ label: "Weekly", days: 7 },
{ label: "2 weeks", days: 14 },
{ label: "Monthly", days: 30 },
{ label: "2 months", days: 60 },
{ label: "Quarterly", days: 90 },
{ label: "6 months", days: 180 },
{ label: "None", days: null },
];

6
app/src/lib/useStore.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useSyncExternalStore } from "react";
import { getState, subscribe, type State } from "./store";
export function useStore(): State {
return useSyncExternalStore(subscribe, getState, getState);
}

20
app/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./src/*"
],
"@/assets/*": [
"./assets/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}

88
docs/SCOPE.md Normal file
View File

@@ -0,0 +1,88 @@
# Kin — scope & architecture
*Written 2026-08-09, at project start.*
## What this is
A personal "relational wealth" app: the single place that answers **who
haven't I talked to in too long?** and makes fixing that a two-tap action.
Inspired by Monica CRM, but deliberately thin — one user, one server, no
accounts, no social features.
## Product principles
1. **The due list is the product.** Contacts, notes, and history exist to
feed one screen: who to reach out to today. Everything else is secondary.
2. **Logging must be cheaper than not logging.** Two taps ("text, today,
done") or the log stays empty and the due list lies. The old web CRM had
44 people and 0 interactions — that's the failure mode to design against.
3. **Cadence over guilt.** Each person gets a cadence (weekly → 6-monthly)
or none at all. No global "you're behind on 30 people" — only people you
*chose* to track surface in reminders. Snooze is honest: it defers,
it doesn't fake contact.
4. **Thin client, durable data.** All state lives in the `personal` Postgres
database on the VPS. The phone caches for instant open and offline
reading; writes go straight to the API.
## Architecture
```
Pixel (Kin app, Expo RN) ──Bearer token──▶ Caddy ──▶ crm container (Bun+Hono) ──▶ personal-db (Postgres)
Browser (web UI) ──basic_auth───▶ ↑ same API, same data
```
- **Server**: the existing `crm` container, extended. Hono + porsager/postgres
on Bun. Now has boot-time SQL migrations (`schema_migrations`), a
`GET /api/due` endpoint, cadence/snooze/archive columns, relationship
create/delete, and Bearer-token auth for non-browser clients.
- **Due math** (server): `urgency = days_since_last_contact / cadence_days`,
anchored on `created_at` for never-contacted people so new adds surface
immediately. `overdue` ≥ 1.0, `due_soon` ≥ 0.75. Sorted by urgency, so a
weekly friend 3 days late outranks a yearly contact 3 days late.
- **App**: Expo SDK 57 + expo-router, structured like the GTD app (read-cache
store on `useSyncExternalStore` + AsyncStorage, refresh on
foreground/focus). Dark warm theme, Pixel 9 XL first.
- **Reminders**: local notifications only, no server push. On every sync the
app reschedules a daily digest for the next 7 days ("N people are due for a
catch-up") at the configured hour. If the app isn't opened for a week, the
notifications run out — which is itself the right nudge. Server-side
push via the existing ntfy container is the v2 escape hatch if this proves
too passive.
## Screens
| Screen | Job |
|---|---|
| **Today** (home) | Due list in sections: Reach out / Coming up / On track / Snoozed. Quick-log button on every row. Empty state doubles as onboarding. |
| **People** | Whole network, client-side search + tag filter (44 people — no server round-trips). |
| **Person** | Contact actions (Message/Call/WhatsApp/Email deep links), cadence picker, log + snooze, notes, relationships, history (long-press to delete). |
| **Log** (modal) | Type chips + today/yesterday + optional note. Two taps minimum. |
| **Add/Edit** (modal) | Name, phone, email, location, tags, notes, cadence. |
| **Settings** (modal) | Server URL, token, reminder hour. Probes the API before saving. |
## Deliberately out of v1
- **Offline write queue** — GTD's op-queue store is proven but heavy; a
relationships app tolerates "log it when you're back online". Revisit if it
annoys in practice.
- **Birthday reminders** — column exists (`people.birthday`), UI doesn't.
- **Relationship editing in-app** — API supports create/delete now; app only
displays. Add when graph curation actually matters.
- **Web UI parity** — the vanilla-JS web UI still works for bulk edits at
crm.rehbock.xyz; it doesn't know about cadence yet.
- **Contact import from the phone** — seed data came from the newsletter
subscriber list; `expo-contacts` import is a v2 candidate.
- **Multi-user / open-source hardening** — single-user by design. The repo is
structured to be open-sourceable (no secrets in git, migrations from
scratch, auth documented), but no login system until someone else needs it.
## Infrastructure facts
- VPS dir: `~/personal/crm` (container `crm`, network `caddy_net` +
`personal-db_default`). DB: `personal` in the shared `personal-db` Postgres.
- Caddy: `crm.rehbock.xyz`; Bearer requests bypass basic_auth (app validates),
everything else challenges.
- CI: Gitea Actions on the desktop runner (label `desktop`), signed APK per
push to main, released as `kin-v1.<run>.apk` for Obtainium.
- Keystore: `~/.android-keys/kin-release.jks`, alias `kin` — distinct from
GTD's so Android treats the apps independently.

3
server/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.env
.env.token.local

5
server/.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Copy to .env on the server. Never commit real values.
DATABASE_URL=postgres://marcus:CHANGE_ME@personal-db:5432/personal
PORT=3000
# Bearer token the mobile app authenticates with (any long random string).
API_TOKEN=CHANGE_ME

16
server/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM oven/bun:1-alpine
WORKDIR /app
# Install deps first for better layer caching.
COPY package.json ./
RUN bun install
# App source
COPY src ./src
COPY migrations ./migrations
COPY public ./public
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]

22
server/docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
crm:
build: .
container_name: crm
restart: unless-stopped
env_file: .env
networks:
# Reach Postgres by service name `personal-db` on its network.
- personal-db_default
# Be reachable by paico-proxy for crm.rehbock.xyz.
- caddy_net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 30s
timeout: 5s
retries: 3
networks:
personal-db_default:
external: true
caddy_net:
external: true

View File

@@ -0,0 +1,73 @@
-- Baseline: the schema that existed in the live `personal` DB before this repo
-- had migrations. Fully idempotent so it no-ops against the live DB and builds
-- everything from scratch on a fresh install.
create extension if not exists "uuid-ossp";
create or replace function set_updated_at() returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
create table if not exists people (
id uuid primary key default uuid_generate_v4(),
full_name text not null,
first_name text,
last_name text,
email text,
phone text,
tags text[] not null default '{}',
notes text,
location text,
source text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists people_email_lower_uniq
on people (lower(email)) where email is not null;
create index if not exists people_tags_gin on people using gin (tags);
create table if not exists interactions (
id uuid primary key default uuid_generate_v4(),
person_id uuid not null references people(id) on delete cascade,
type text,
occurred_at timestamptz not null default now(),
summary text,
notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists interactions_person_idx on interactions (person_id);
create index if not exists interactions_occurred_at_idx on interactions (occurred_at desc);
create table if not exists relationships (
id uuid primary key default uuid_generate_v4(),
from_person_id uuid not null references people(id) on delete cascade,
to_person_id uuid not null references people(id) on delete cascade,
type text not null,
notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint relationships_no_self check (from_person_id <> to_person_id),
constraint relationships_unique unique (from_person_id, to_person_id, type)
);
create index if not exists relationships_from_idx on relationships (from_person_id);
create index if not exists relationships_to_idx on relationships (to_person_id);
-- Triggers: drop-and-recreate is the only idempotent form pre-PG14.
drop trigger if exists people_set_updated_at on people;
create trigger people_set_updated_at before update on people
for each row execute function set_updated_at();
drop trigger if exists interactions_set_updated_at on interactions;
create trigger interactions_set_updated_at before update on interactions
for each row execute function set_updated_at();
drop trigger if exists relationships_set_updated_at on relationships;
create trigger relationships_set_updated_at before update on relationships
for each row execute function set_updated_at();

View File

@@ -0,0 +1,11 @@
-- Relational-wealth fields: how often I want to be in touch with each person,
-- and enough state to drive the "who's due" list.
alter table people add column if not exists cadence_days integer,
add column if not exists snoozed_until date,
add column if not exists archived boolean not null default false,
add column if not exists birthday date;
comment on column people.cadence_days is 'Target days between contacts; null = no reminder for this person';
comment on column people.snoozed_until is 'Hide from the due list until this date';
comment on column people.archived is 'Hidden from lists and due computation, kept for history';

15
server/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "kin-server",
"version": "1.0.0",
"description": "Kin — personal relationships CRM: contacts, interactions, cadence over Postgres",
"type": "module",
"module": "src/index.ts",
"scripts": {
"dev": "bun --watch run src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"hono": "^4.6.14",
"postgres": "^3.4.5"
}
}

232
server/public/app.js Normal file
View File

@@ -0,0 +1,232 @@
// --- tiny helpers ---------------------------------------------------------
const $ = (sel) => document.querySelector(sel);
const el = (tag, props = {}, ...kids) => {
const n = Object.assign(document.createElement(tag), props);
for (const k of kids) n.append(k?.nodeType ? k : document.createTextNode(k ?? ""));
return n;
};
const api = async (path, opts) => {
const res = await fetch("/api" + path, {
headers: { "Content-Type": "application/json" },
...opts,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || res.statusText);
}
return res.status === 204 ? null : res.json();
};
function toast(msg) {
let t = $(".toast");
if (!t) { t = el("div", { className: "toast" }); document.body.append(t); }
t.textContent = msg;
t.classList.add("show");
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove("show"), 2200);
}
function relTime(iso) {
if (!iso) return "never";
const d = new Date(iso), now = new Date();
const days = Math.floor((now - d) / 86400000);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 30) return days + "d ago";
if (days < 365) return Math.floor(days / 30) + "mo ago";
return Math.floor(days / 365) + "y ago";
}
const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "");
// --- state ----------------------------------------------------------------
let state = { q: "", tag: "", sort: "name", selectedId: null };
// --- list -----------------------------------------------------------------
async function refresh() {
const [stats, list] = await Promise.all([
api("/stats"),
api(`/people?q=${encodeURIComponent(state.q)}&tag=${encodeURIComponent(state.tag)}&sort=${state.sort}`),
]);
$("#stats").textContent = `${stats.people} contacts · ${stats.interactions} interactions · ${stats.relationships} links`;
renderList(list);
}
function renderList(list) {
const ul = $("#list");
ul.innerHTML = "";
if (!list.length) { ul.append(el("li", { className: "hint" }, "No matches.")); return; }
for (const p of list) {
const meta = el("div", { className: "meta" },
el("span", {}, `📇 ${relTime(p.last_contacted)}`),
p.location ? el("span", {}, `📍 ${p.location}`) : "",
p.interaction_count ? el("span", {}, `${p.interaction_count}×`) : "",
);
(p.tags || []).forEach((t) => meta.append(el("span", { className: "tagchip" }, t)));
const li = el("li", { className: p.id === state.selectedId ? "active" : "" },
el("span", { className: "name" }, p.full_name),
meta,
);
li.onclick = () => selectPerson(p.id);
ul.append(li);
}
}
async function loadTags() {
const tags = await api("/tags");
const sel = $("#tag");
sel.innerHTML = '<option value="">All tags</option>';
tags.forEach((t) => sel.append(el("option", { value: t.tag }, `${t.tag} (${t.count})`)));
sel.value = state.tag;
}
// --- detail ---------------------------------------------------------------
async function selectPerson(id) {
state.selectedId = id;
document.querySelectorAll(".list li").forEach((li) => li.classList.remove("active"));
const p = await api(`/people/${id}`);
renderDetail(p);
refresh();
}
function field(label, input) {
return el("div", { className: "row" }, el("label", {}, label), el("div", { className: "field" }, input));
}
function renderDetail(p) {
const d = $("#detail");
d.innerHTML = "";
// --- editable info block ---
const nameI = el("input", { value: p.full_name });
const emailI = el("input", { value: p.email || "", type: "email" });
const phoneI = el("input", { value: p.phone || "", type: "tel", placeholder: "+61…" });
const locationI = el("input", { value: p.location || "", placeholder: "City, country" });
const tagsI = el("input", { value: (p.tags || []).join(", ") });
const notesI = el("textarea", { value: p.notes || "" });
const save = el("button", { className: "primary" }, "Save");
save.onclick = async () => {
try {
await api(`/people/${p.id}`, {
method: "PATCH",
body: JSON.stringify({ full_name: nameI.value, email: emailI.value, phone: phoneI.value, location: locationI.value, tags: tagsI.value, notes: notesI.value }),
});
toast("Saved");
await loadTags();
selectPerson(p.id);
} catch (e) { toast(e.message); }
};
const del = el("button", { className: "danger" }, "Delete");
del.onclick = async () => {
if (!confirm(`Delete ${p.full_name}? This removes their interactions too.`)) return;
try {
await api(`/people/${p.id}`, { method: "DELETE" });
toast("Deleted");
state.selectedId = null;
d.innerHTML = '<p class="empty">Select a contact, or add a new one.</p>';
loadTags(); refresh();
} catch (e) { toast(e.message); }
};
const info = el("section", { className: "block" },
el("h2", {}, p.full_name),
el("p", { className: "sub" }, `Last contacted ${relTime(p.last_contacted || (p.interactions[0]?.occurred_at))}`),
field("Name", nameI), field("Email", emailI), field("Phone", phoneI), field("Location", locationI), field("Tags", tagsI), field("Notes", notesI),
el("div", { className: "actions" }, save, del),
);
d.append(info);
// --- relationships ---
if (p.relationships?.length) {
const rl = el("ul", { className: "timeline" });
p.relationships.forEach((r) => {
const link = el("a", { href: "#", style: "color:var(--accent)" }, r.other_name);
link.onclick = (e) => { e.preventDefault(); selectPerson(r.other_id); };
rl.append(el("li", {}, el("span", { className: "badge" }, r.type), " ", link, r.notes ? `${r.notes}` : ""));
});
d.append(el("section", { className: "block" }, el("h3", {}, "Relationships"), rl));
}
// --- interactions ---
const typeI = el("select", {},
...["call", "message", "email", "meetup", "note", "other"].map((t) => el("option", { value: t }, t)));
const whenI = el("input", { type: "date", value: new Date().toISOString().slice(0, 10) });
const summaryI = el("input", { placeholder: "What happened? (short summary)" });
const inotesI = el("textarea", { placeholder: "Details (optional)" });
const logBtn = el("button", { className: "primary" }, "Log interaction");
logBtn.onclick = async () => {
if (!summaryI.value.trim() && !inotesI.value.trim()) return toast("Add a summary first");
try {
await api(`/people/${p.id}/interactions`, {
method: "POST",
body: JSON.stringify({ type: typeI.value, occurred_at: whenI.value, summary: summaryI.value, notes: inotesI.value }),
});
toast("Logged");
selectPerson(p.id);
} catch (e) { toast(e.message); }
};
const timeline = el("ul", { className: "timeline" });
if (!p.interactions.length) timeline.append(el("li", { className: "hint" }, "No interactions logged yet."));
p.interactions.forEach((i) => {
const delI = el("button", { className: "ghost", style: "float:right;color:var(--muted)" }, "✕");
delI.onclick = async () => {
if (!confirm("Delete this interaction?")) return;
await api(`/interactions/${i.id}`, { method: "DELETE" });
toast("Removed"); selectPerson(p.id);
};
timeline.append(el("li", {},
delI,
el("div", { className: "when" }, `${fmtDate(i.occurred_at)} · `, el("span", { className: "badge" }, i.type || "note")),
el("div", {}, i.summary || ""),
i.notes ? el("div", { className: "hint" }, i.notes) : "",
));
});
d.append(el("section", { className: "block" },
el("h3", {}, "Log an interaction"),
el("div", { className: "inline-form" }, field("Type", typeI), field("Date", whenI), field("Summary", summaryI), field("Notes", inotesI), logBtn),
));
d.append(el("section", { className: "block" }, el("h3", {}, "History"), timeline));
}
// --- add contact ----------------------------------------------------------
function showAddForm() {
state.selectedId = null;
document.querySelectorAll(".list li").forEach((li) => li.classList.remove("active"));
const d = $("#detail");
d.innerHTML = "";
const nameI = el("input", { placeholder: "Full name *" });
const emailI = el("input", { type: "email", placeholder: "email@example.com" });
const phoneI = el("input", { type: "tel", placeholder: "+61…" });
const locationI = el("input", { placeholder: "City, country" });
const tagsI = el("input", { placeholder: "comma, separated, tags" });
const notesI = el("textarea", { placeholder: "Notes" });
const create = el("button", { className: "primary" }, "Create contact");
create.onclick = async () => {
if (!nameI.value.trim()) return toast("Name is required");
try {
const p = await api("/people", {
method: "POST",
body: JSON.stringify({ full_name: nameI.value, email: emailI.value, phone: phoneI.value, location: locationI.value, tags: tagsI.value, notes: notesI.value }),
});
toast("Created");
await loadTags();
selectPerson(p.id);
} catch (e) { toast(e.message); }
};
d.append(el("section", { className: "block" },
el("h3", {}, "New contact"),
field("Name", nameI), field("Email", emailI), field("Phone", phoneI), field("Location", locationI), field("Tags", tagsI), field("Notes", notesI),
el("div", { className: "actions" }, create),
));
}
// --- wire up --------------------------------------------------------------
let searchTimer;
$("#search").oninput = (e) => { state.q = e.target.value; clearTimeout(searchTimer); searchTimer = setTimeout(refresh, 200); };
$("#tag").onchange = (e) => { state.tag = e.target.value; refresh(); };
$("#sort").onchange = (e) => { state.sort = e.target.value; refresh(); };
$("#add-btn").onclick = showAddForm;
loadTags();
refresh();

34
server/public/index.html Normal file
View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Personal CRM</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header>
<h1>Personal CRM</h1>
<div class="stats" id="stats"></div>
<div class="controls">
<input id="search" type="search" placeholder="Search name or email…" autocomplete="off" />
<select id="tag"><option value="">All tags</option></select>
<select id="sort">
<option value="name">Sort: Name</option>
<option value="recent">Sort: Recently contacted</option>
<option value="stale">Sort: Out of touch</option>
</select>
<button id="add-btn" class="primary">+ Contact</button>
</div>
</header>
<main>
<ul id="list" class="list"></ul>
<section id="detail" class="detail">
<p class="empty">Select a contact, or add a new one.</p>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

102
server/public/styles.css Normal file
View File

@@ -0,0 +1,102 @@
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1e222b;
--border: #2a2f3a;
--text: #e6e8ec;
--muted: #8b93a1;
--accent: #4f8cff;
--accent-2: #2d6ae0;
--danger: #e5484d;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
background: var(--panel);
position: sticky;
top: 0;
z-index: 5;
}
header h1 { font-size: 18px; margin: 0; }
.stats { color: var(--muted); font-size: 13px; }
.controls { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
input, select, textarea, button {
font: inherit;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
button { cursor: pointer; }
button.primary { background: var(--accent); border-color: var(--accent-2); color: #fff; font-weight: 600; }
button.primary:hover { background: var(--accent-2); }
button.ghost { background: transparent; }
button.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
main { display: grid; grid-template-columns: 340px 1fr; min-height: calc(100vh - 66px); }
.list { list-style: none; margin: 0; padding: 8px; border-right: 1px solid var(--border); overflow-y: auto; }
.list li {
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 3px;
}
.list li:hover { background: var(--panel); }
.list li.active { background: var(--panel-2); outline: 1px solid var(--accent); }
.list .name { font-weight: 600; }
.list .meta { font-size: 12px; color: var(--muted); display: flex; gap: 8px; flex-wrap: wrap; }
.tagchip { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: #23324d; color: #9dbcff; }
.detail { padding: 24px; overflow-y: auto; }
.detail .empty { color: var(--muted); }
.detail h2 { margin: 0 0 4px; }
.detail .sub { color: var(--muted); margin: 0 0 16px; }
.detail section.block { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 16px; }
.detail section.block h3 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.row { display: flex; gap: 10px; margin-bottom: 10px; }
.row label { flex: 0 0 110px; color: var(--muted); padding-top: 8px; }
.row .field { flex: 1; }
.field input, .field textarea, .field select { width: 100%; }
textarea { resize: vertical; min-height: 60px; }
.timeline { list-style: none; margin: 0; padding: 0; }
.timeline li { padding: 10px 0; border-top: 1px solid var(--border); }
.timeline li:first-child { border-top: none; }
.timeline .when { font-size: 12px; color: var(--muted); }
.badge { font-size: 11px; padding: 1px 8px; border-radius: 999px; background: var(--panel-2); border: 1px solid var(--border); }
.actions { display: flex; gap: 8px; margin-top: 8px; }
.inline-form { display: grid; gap: 8px; }
.hint { color: var(--muted); font-size: 12px; }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--panel-2); border: 1px solid var(--border); padding: 10px 16px;
border-radius: 8px; opacity: 0; transition: opacity .2s; pointer-events: none;
}
.toast.show { opacity: 1; }
@media (max-width: 720px) {
main { grid-template-columns: 1fr; }
.list { max-height: 40vh; }
}

13
server/src/db.ts Normal file
View File

@@ -0,0 +1,13 @@
import postgres from "postgres";
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set");
// Single shared connection pool for the app.
const sql = postgres(url, {
max: 5,
idle_timeout: 30,
onnotice: () => {}, // silence NOTICE spam
});
export default sql;

46
server/src/index.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { logger } from "hono/logger";
import { migrate } from "./migrate";
import people from "./routes/people";
import due from "./routes/due";
const app = new Hono();
app.use("*", logger());
// Health check — used by the container healthcheck.
app.get("/healthz", (c) => c.text("ok"));
// API auth. Two ways in, matching the Caddy config for crm.rehbock.xyz:
// - Browser/web UI: Caddy enforces basic_auth before proxying, so requests
// without a Bearer header have already been authenticated upstream.
// - Mobile app: sends `Authorization: Bearer <API_TOKEN>`; Caddy passes
// Bearer requests straight through and WE are the auth layer, so any
// Bearer value that doesn't match the token is rejected here.
// Direct access on the Docker networks is unauthenticated by design — the
// port is never published on the host.
const API_TOKEN = process.env.API_TOKEN;
app.use("/api/*", async (c, next) => {
const auth = c.req.header("authorization") ?? "";
if (auth.startsWith("Bearer ")) {
if (!API_TOKEN || auth !== `Bearer ${API_TOKEN}`) {
return c.json({ error: "unauthorized" }, 401);
}
}
await next();
});
// API
app.route("/api", due);
app.route("/api", people);
// Static front end (public/)
app.use("/*", serveStatic({ root: "./public" }));
await migrate();
const port = Number(process.env.PORT ?? 3000);
console.log(`kin server listening on :${port}`);
export default { port, fetch: app.fetch };

31
server/src/migrate.ts Normal file
View File

@@ -0,0 +1,31 @@
import { readdir, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import sql from "./db";
const MIGRATIONS_DIR = fileURLToPath(new URL("../migrations", import.meta.url));
// Applies migrations/*.sql in filename order, once each, recorded in
// schema_migrations. Runs at boot before the server starts listening.
export async function migrate() {
await sql`
create table if not exists schema_migrations (
name text primary key,
applied_at timestamptz not null default now()
)
`;
const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith(".sql")).sort();
const applied = new Set(
(await sql`select name from schema_migrations`).map((r) => r.name)
);
for (const file of files) {
if (applied.has(file)) continue;
const text = await readFile(`${MIGRATIONS_DIR}/${file}`, "utf8");
await sql.begin(async (tx) => {
await tx.unsafe(text);
await tx`insert into schema_migrations (name) values (${file})`;
});
console.log(`migrated: ${file}`);
}
}

65
server/src/routes/due.ts Normal file
View File

@@ -0,0 +1,65 @@
import { Hono } from "hono";
import sql from "../db";
const due = new Hono();
// The heart of the app: everyone with a cadence, annotated with how overdue
// they are. `urgency` = days_since / cadence_days (>= 1 means overdue), so a
// weekly friend 3 days late outranks a yearly contact 3 days late.
// People never contacted anchor on created_at so they surface immediately.
due.get("/due", async (c) => {
const rows = await sql`
SELECT
p.id, p.full_name, p.email, p.phone, p.tags, p.location,
p.cadence_days, p.snoozed_until, p.birthday,
li.last_contacted, li.last_type,
COALESCE(li.interaction_count, 0)::int AS interaction_count,
GREATEST(0, EXTRACT(epoch FROM now() - COALESCE(li.last_contacted, p.created_at)) / 86400)::int AS days_since
FROM people p
LEFT JOIN (
SELECT person_id,
max(occurred_at) AS last_contacted,
(array_agg(type ORDER BY occurred_at DESC))[1] AS last_type,
count(*) AS interaction_count
FROM interactions
GROUP BY person_id
) li ON li.person_id = p.id
WHERE NOT p.archived
AND p.cadence_days IS NOT NULL
`;
const today = new Date().toISOString().slice(0, 10);
const out = rows
.map((r) => {
const urgency = r.days_since / r.cadence_days;
const snoozed = r.snoozed_until != null && String(r.snoozed_until) > today;
return {
...r,
urgency: Math.round(urgency * 100) / 100,
due_in_days: Math.ceil(r.cadence_days - r.days_since),
snoozed,
status: snoozed ? "snoozed" : urgency >= 1 ? "overdue" : urgency >= 0.75 ? "due_soon" : "ok",
};
})
.sort((a, b) => b.urgency - a.urgency);
return c.json(out);
});
// Snooze someone off the due list for N days (default 7). days=0 unsnoozes.
due.post("/people/:id/snooze", async (c) => {
const id = c.req.param("id");
const body = await c.req.json().catch(() => ({}));
const days = Number.isFinite(Number(body.days)) ? Number(body.days) : 7;
const until =
days <= 0 ? null : new Date(Date.now() + days * 86400_000).toISOString().slice(0, 10);
const [row] = await sql`
UPDATE people SET snoozed_until = ${until} WHERE id = ${id}
RETURNING id, snoozed_until
`;
if (!row) return c.json({ error: "not found" }, 404);
return c.json(row);
});
export default due;

Some files were not shown because too many files have changed in this diff Show More