Compare commits

...

20 Commits

Author SHA1 Message Date
1117b4adc0 add drizzle schema 2026-03-07 13:24:43 +02:00
8de1f87435 remove pnpm workspace 2026-03-07 12:49:48 +02:00
d82416e4a3 remove build config 2026-03-07 12:36:27 +02:00
926fc0fd0b Move to better sqlite3 2026-03-07 12:34:04 +02:00
0370930894 Update preset 2026-03-07 09:56:33 +02:00
8831857007 directly run server 2026-03-06 22:06:57 +02:00
095463cafa Update migrations 2026-03-06 21:59:52 +02:00
742242ffb8 Add nixpacks.toml for Railway deployment with bun 2026-03-06 19:44:10 +02:00
61de53c014 Update lockfile 2026-03-06 19:33:45 +02:00
cda02e35fb Add start task 2026-03-06 19:28:55 +02:00
41c71a9877 Update README with bun commands and add .env setup 2026-03-06 19:17:52 +02:00
d8be1c0a69 Feed the database 2026-03-06 19:11:41 +02:00
1b31f3194d Move to sqlite off cloud 2026-03-06 18:49:41 +02:00
27b7d87e68 Use openrouter 2026-03-05 16:28:19 +02:00
01e346e157 Use bun 2026-03-05 15:30:34 +02:00
1df8a3f452 Use bun 2026-03-05 15:28:55 +02:00
aad888ff2c Update prompt 2025-09-17 21:27:21 +03:00
906c9765b5 Update prompt and model 2025-09-16 09:35:17 +03:00
559a7d9a21 Update npm 2025-08-09 12:36:51 +03:00
284c792a92 Delete package-lock.json 2025-08-09 12:29:38 +03:00
34 changed files with 5087 additions and 21812 deletions

View File

@@ -1,9 +1,8 @@
NUXT_OAUTH_STRAVA_CLIENT_ID=[YOUR_STRAVA_CLIENT_ID]
NUXT_OAUTH_STRAVA_CLIENT_SECRET=[YOUR_STRAVA_CLIENT_SECRET]
NUXT_SESSION_PASSWORD=[YOUR_SESSION_PASSWORD]
NUXT_STRAVA_VERIFY_TOKEN=[YOUR_STRAVA_VERIFY_TOKEN]
NUXT_OPENAI_API_KEY=[YOUR_OPENAI_API_KEY]
NUXT_PUBLIC_APTABASE_APP_KEY=[YOUR_APTABASE_APP_KEY]
NUXT_WEBHOOKS_URL=[YOUR_WEBHOOKS_URL]
NUXT_DATABASE_URL=[YOUR_DATABASE_URL]
NUXT_HOOKDECK_KEY=[YOUR_HOOKDECK_KEY]
NUXT_OAUTH_STRAVA_CLIENT_ID=your_client_id
NUXT_OAUTH_STRAVA_CLIENT_SECRET=your_client_secret
NUXT_SESSION_PASSWORD=your_session_password_min_32_chars
NUXT_STRAVA_VERIFY_TOKEN=your_verify_token
NUXT_OPENAI_API_KEY=your_openai_api_key
NUXT_DATABASE_URL=file:./tmp/ghostwriter.db
NUXT_HOOKDECK_KEY=your_hookdeck_key
NUXT_OPENROUTER_API_KEY=your_openrouter_api_key

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ logs
.env.*
!.env.example
.vercel
tmp/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
shamefully-hoist=true

View File

@@ -2,24 +2,47 @@
## Strava Activity Title & Description Generator
Ghostwriter is a Nuxt-based application that helps athletes generate fun and creative titles and descriptions for your Strava activities.
Ghostwriter is a Nuxt app that conjures up the most hilarious, epic, and mildly unhinged titles and descriptions for your Strava activities. Because let's be honest, "Morning Run" doesn't quite capture the chaos of dodging dogs at 6 AM.
## Installation
Built with Nuxt 4, Bun (it's fast, duh), Drizzle ORM, and @nuxt/ui. Powers through your Strava data like a caffeinated marathoner.
1. Clone the repository:
## Quick Start
1. Copy the example env file:
```bash
git clone [repository_url]
cp .env.example .env
```
2. Navigate to the project directory:
2. Install and run:
```bash
cd ghostwriter
bun install
bun run dev
```
3. Install dependencies:
```bash
npm install
```
4. Run the development server:
```bash
npm run dev
```
5. Access the application at http://localhost:3000
Open http://localhost:3000 and start ghostwriting those activities into legend.
## Commands
| Command | What it does |
|---------|--------------|
| `bun run dev` | Spin up the dev server |
| `bun run build` | Build for production |
## Tech Stack
- **Nuxt 4** — The skeleton
- **Bun** — Runtime so fast it's basically cheating
- **Drizzle ORM** — Database wizardry
- **@nuxt/ui v3** — Beautiful UI components
- **SQLite** — Your data's cozy little home
## Contributing
1. Fork it
2. Create a branch (`git checkout -b fix-that-bug-i-guess`)
3. Commit your changes
4. Push and open a PR
---
*Ghostwriter: Making your Strava activities slightly less embarrassing since 2025.*

28
agents.md Normal file
View File

@@ -0,0 +1,28 @@
# Agents
## Commands
```bash
# Development
pnpm run dev
# Build
pnpm run build
# Generate static site
pnpm run generate
# Type checking
pnpm exec vue-tsc --noEmit
# Database
pnpm exec drizzle-kit push
pnpm exec drizzle-kit studio
```
## Project Info
- Nuxt 4
- Uses pnpm as package manager and runtime
- Database: Drizzle ORM with sqlite
- UI: @nuxt/ui v4

View File

@@ -7,46 +7,48 @@ const { openInPopup } = useUserSession();
</script>
<template>
<UContainer
class="flex flex-col items-center gap-4 justify-center w-full p-16"
>
<UCard class="max-w-sm grid gap-6 justify-center items-center">
<div class="flex flex-col gap-10 items-center justify-center">
<div class="flex gap-2 items-center">
<div class="font-bold text-xl tracking-tight font-fira-code">
Ghostwriter
</div>
<NuxtImg src="/ghostwriter-logo.png" class="size-9" />
</div>
<div class="grid gap-2">
<div class="text-center text-2xl font-bold">
Sign in to your account.
</div>
<div class="text-center">
Connect with Strava to automatically add personalized titles and
descriptions to your activities.
</div>
</div>
<div
aria-role="button"
@click="openInPopup('/auth/strava')"
class="cursor-pointer w-full max-w-[200px]"
>
<NuxtImg
src="/images/connect-with-strava.svg"
class="w-full max-w-[200px]"
/>
</div>
<div class="text-sm text-gray-500">
By signing in, you agree to our
<NuxtLink
href="https://www.ghostwriter.rocks/privacy"
class="text-primary-500"
>Privacy Policy</NuxtLink
>.
</div>
</div>
</UCard>
</UContainer>
<AppFooter />
<UContainer
class="flex flex-col items-center gap-4 justify-center w-full p-16"
>
<UCard class="max-w-sm grid gap-6 justify-center items-center">
<div class="flex flex-col gap-10 items-center justify-center">
<div class="flex gap-2 items-center">
<div
class="font-bold text-xl tracking-tight font-fira-code"
>
Ghostwriter
</div>
<NuxtImg src="/ghostwriter-logo.png" class="size-9" />
</div>
<div class="grid gap-2">
<div class="text-center text-2xl font-bold">
Sign in to your account.
</div>
<div class="text-center">
Connect with Strava to automatically add personalized
titles and descriptions to your activities.
</div>
</div>
<div
aria-role="button"
@click="openInPopup('/auth/strava')"
class="cursor-pointer w-full max-w-[200px]"
>
<NuxtImg
src="/images/connect-with-strava.svg"
class="w-full max-w-[200px]"
/>
</div>
<div class="text-sm text-gray-500">
By signing in, you agree to our
<NuxtLink
href="https://www.ghostwriter.rocks/privacy"
class="text-primary-500"
>Privacy Policy</NuxtLink
>.
</div>
</div>
</UCard>
</UContainer>
<AppFooter />
</template>

View File

@@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit";
import { get } from "radash";
export default defineConfig({
dialect: "postgresql",
dialect: "sqlite",
schema: "./server/database/schema.ts",
out: "./server/database/migrations",
dbCredentials: {

View File

@@ -1,12 +1,12 @@
<template>
<UApp :toaster="{ position: 'bottom-center' }">
<AuthState v-slot="{ loggedIn }">
<Register v-if="!loggedIn" />
<template v-if="loggedIn">
<AppBar />
<slot />
<AppFooter />
</template>
</AuthState>
</UApp>
<UApp :toaster="{ position: 'bottom-center' }">
<AuthState v-slot="{ loggedIn }">
<Register v-if="!loggedIn" />
<template v-if="loggedIn">
<AppBar />
<slot />
<AppFooter />
</template>
</AuthState>
</UApp>
</template>

View File

@@ -12,13 +12,8 @@ export default defineNuxtConfig({
webhooksUrl: "",
stravaVerifyToken: "",
hookdeckKey: "",
openaiApiKey: "",
openrouterApiKey: "",
databaseUrl: "",
public: {
posthogPublicKey: "",
posthogHost: "",
posthogDefaults: "2025-05-24",
},
},
future: { compatibilityVersion: 4 },
compatibilityDate: "2025-03-01",
@@ -55,4 +50,10 @@ export default defineNuxtConfig({
],
},
},
nitro: {
preset: "node-server",
experimental: {
tasks: true,
},
},
});

16152
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,42 +6,49 @@
"build": "nuxt build",
"dev": "nuxt dev --host=0.0.0.0",
"generate": "nuxt generate",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@formkit/tempo": "^0.1.2",
"@google/genai": "^1.13.0",
"@neondatabase/serverless": "^1.0.1",
"@nuxt/icon": "1.15.0",
"@nuxt/image": "^1.11.0",
"@nuxt/ui": "3.3.0",
"@formkit/tempo": "^1.0.0",
"@nuxt/icon": "^2.2.1",
"@nuxt/image": "^2.0.0",
"@nuxt/ui": "4.5.1",
"@vee-validate/nuxt": "^4.15.1",
"@vercel/functions": "^2.2.8",
"@vueuse/nuxt": "^13.6.0",
"@vueuse/nuxt": "^14.2.1",
"better-sqlite3": "^12.6.2",
"destr": "^2.0.5",
"drizzle-orm": "^0.44.4",
"nuxt": "^4.0.3",
"nuxt-auth-utils": "0.5.23",
"openai": "^5.12.2",
"posthog-js": "^1.259.0",
"posthog-node": "^5.6.0",
"nuxt": "^4.3.1",
"nuxt-auth-utils": "0.5.29",
"openai": "^6.27.0",
"radash": "^12.1.1",
"ts-pattern": "^5.8.0",
"ts-pattern": "^5.9.0",
"url": "^0.11.4",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"zod": "^4.0.16"
"vue": "^3.5.29",
"vue-router": "^5.0.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@iconify-json/heroicons": "^1.2.3",
"@iconify-json/lucide": "^1.2.95",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.3.5",
"drizzle-kit": "^0.31.4",
"typescript": "^5.9.2",
"vue-tsc": "^3.0.5"
"vue-tsc": "^3.2.5"
},
"trustedDependencies": [
"@parcel/watcher",
"better-sqlite3",
"esbuild",
"sharp",
"vue-demi",
"workerd"
]
],
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
]
}
}

View File

@@ -4,206 +4,213 @@ useHead({ title: "Ghostwriter" });
const { user } = useUserSession();
const stravaLink = computed(() => {
return `https://www.strava.com/athletes/${toValue(user).id}`;
return `https://www.strava.com/athletes/${toValue(user).id}`;
});
interface FormData {
enabled: boolean;
language: string;
units: string;
tone: string[];
highlights: string[];
enabled: boolean;
language: string;
units: string;
tone: string[];
highlights: string[];
}
const preferences = useState<FormData>("preferences", () => ({
enabled: false,
language: "English",
units: "Metric",
tone: [],
highlights: [],
enabled: false,
language: "English",
units: "Metric",
tone: [],
highlights: [],
}));
const { data, status } = useFetch("/api/preferences", {
lazy: true,
lazy: true,
});
// @ts-expect-error typing issue - missing transform
syncRef(data, preferences, { direction: "ltr", deep: true });
onMounted(() => {
saveOp.resume();
saveOp.resume();
});
const saveOp = watchPausable(
preferences,
async (newData) => {
await $fetch("/api/preferences", {
method: "PUT",
body: toValue(preferences),
});
},
{
initialState: "paused",
deep: true,
},
preferences,
async (newData) => {
await $fetch("/api/preferences", {
method: "PUT",
body: toValue(preferences),
});
},
{
initialState: "paused",
deep: true,
},
);
</script>
<template>
<UContainer class="max-w-2xl py-8 flex flex-col gap-4">
<div class="font-bold text-lg">Welcome to Ghostwriter!</div>
<UContainer class="max-w-2xl py-8 flex flex-col gap-4">
<div class="font-bold text-lg">Welcome to Ghostwriter!</div>
<div>
Let's generate fun and engaging titles and descriptions for your Strava
activities automatically, right when they are created. Customize your
preferences below.
</div>
<div>
Let's generate fun and engaging titles and descriptions for your
Strava activities automatically, right when they are created.
Customize your preferences below.
</div>
<div>
Add a touch of creativity to your Strava workouts. Simply enable it and
choose your language, and we'll do the rest!
</div>
</UContainer>
<div>
Add a touch of creativity to your Strava workouts. Simply enable it
and choose your language, and we'll do the rest!
</div>
</UContainer>
<UContainer class="max-w-2xl py-8 flex flex-col gap-4">
<div class="font-bold text-lg"> Support</div>
<UCard class="">
<div class="flex flex-col gap-8">
Ghostwriter 👻 is free to use, but it takes time and resources to keep
it running smoothly. If you enjoy it, consider supporting the app and
its creator - every bit helps!
</div>
<template #footer>
<ULink href="https://buymeacoffee.com/mariosant" target="_blank">
<NuxtImg
src="images/bmac-orange-button.png"
height="32px"
class="h-8"
/>
</ULink>
</template>
</UCard>
</UContainer>
<UContainer class="max-w-2xl py-8 flex flex-col gap-4">
<div class="font-bold text-lg"> Support</div>
<UCard class="">
<div class="flex flex-col gap-8">
Ghostwriter 👻 is free to use, but it takes time and resources
to keep it running smoothly. If you enjoy it, consider
supporting the app and its creator - every bit helps!
</div>
<template #footer>
<ULink
href="https://buymeacoffee.com/mariosant"
target="_blank"
>
<NuxtImg
src="images/bmac-orange-button.png"
height="32px"
class="h-8"
/>
</ULink>
</template>
</UCard>
</UContainer>
<UContainer
class="max-w-2xl py-8 flex flex-col gap-4"
v-if="status === 'success'"
>
<div class="font-bold text-lg">🛠 Preferences</div>
<UCard class="">
<div class="flex flex-col gap-8">
<CardField>
<template #title> Enabled </template>
<template #description>
Enable/disable automatic generation.
</template>
<template #value>
<USwitch size="lg" v-model="preferences.enabled" />
</template>
</CardField>
<UContainer
class="max-w-2xl py-8 flex flex-col gap-4"
v-if="status === 'success'"
>
<div class="font-bold text-lg">🛠 Preferences</div>
<UCard class="">
<div class="flex flex-col gap-8">
<CardField>
<template #title> Enabled </template>
<template #description>
Enable/disable automatic generation.
</template>
<template #value>
<USwitch size="lg" v-model="preferences.enabled" />
</template>
</CardField>
<CardField>
<template #title> Model language </template>
<template #description>
Language for generated titles and descriptions.
</template>
<template #value>
<USelect
class="min-w-28"
:items="languages"
v-model="preferences.language"
/>
</template>
</CardField>
<CardField>
<template #title> Model language </template>
<template #description>
Language for generated titles and descriptions.
</template>
<template #value>
<USelect
class="min-w-28"
:items="languages"
v-model="preferences.language"
/>
</template>
</CardField>
<CardField>
<template #title> Unit system </template>
<template #description>
Unit system preference, used in descriptions.
</template>
<template #value>
<USelect
class="min-w-28"
v-model="preferences.units"
:items="units"
/>
</template>
</CardField>
<CardField>
<template #title> Unit system </template>
<template #description>
Unit system preference, used in descriptions.
</template>
<template #value>
<USelect
class="min-w-28"
v-model="preferences.units"
:items="units"
/>
</template>
</CardField>
<CardField>
<template #title> Tone </template>
<template #description>
Choose one or more writing styles you like.
</template>
<template #value>
<USelect
multiple
class="min-w-28 max-w-64"
v-model="preferences.tone"
:items="tones"
placeholder="None specified (Use all)"
/>
</template>
</CardField>
<CardField>
<template #title> Tone </template>
<template #description>
Choose one or more writing styles you like.
</template>
<template #value>
<USelect
multiple
class="min-w-28 max-w-64"
v-model="preferences.tone"
:items="tones"
placeholder="None specified (Use all)"
/>
</template>
</CardField>
<CardField>
<template #title> Highlight </template>
<template #description>
Choose what Ghostwriter should focus on.
</template>
<template #value>
<USelect
multiple
class="min-w-28 max-w-64"
:items="highlights"
v-model="preferences.highlights"
placeholder="None specified (Use all)"
/>
</template>
</CardField>
</div>
</UCard>
</UContainer>
<CardField>
<template #title> Highlight </template>
<template #description>
Choose what Ghostwriter should focus on.
</template>
<template #value>
<USelect
multiple
class="min-w-28 max-w-64"
:items="highlights"
v-model="preferences.highlights"
placeholder="None specified (Use all)"
/>
</template>
</CardField>
</div>
</UCard>
</UContainer>
<UContainer class="max-w-2xl py-8 flex flex-col gap-4">
<div class="font-bold text-lg">🪪 Your connected Strava account</div>
<UCard class="bg-neutral-50 dark:bg-slate-800">
<div class="flex flex-col gap-8">
<CardField>
<template #title> Full name </template>
<template #description>
Full name from your linked Strava account.
</template>
<template #value>
{{ user.name }}
</template>
</CardField>
<UContainer class="max-w-2xl py-8 flex flex-col gap-4">
<div class="font-bold text-lg">🪪 Your connected Strava account</div>
<UCard class="bg-neutral-50 dark:bg-slate-800">
<div class="flex flex-col gap-8">
<CardField>
<template #title> Full name </template>
<template #description>
Full name from your linked Strava account.
</template>
<template #value>
{{ user.name }}
</template>
</CardField>
<CardField>
<template #title> Country </template>
<template #description>
Country associated with your Strava account.
</template>
<template #value> {{ user.country }} </template>
</CardField>
<CardField>
<template #title> Country </template>
<template #description>
Country associated with your Strava account.
</template>
<template #value> {{ user.country }} </template>
</CardField>
<CardField>
<template #title> Athlete ID </template>
<template #description>
Your Athlete ID. Click it to view your profile on Strava.</template
>
<CardField>
<template #title> Athlete ID </template>
<template #description>
Your Athlete ID. Click it to view your profile on
Strava.</template
>
<template #value>
<ULink :href="stravaLink" class="underline flex items-center gap-2">
{{ user.id }}
<UIcon
name="heroicons:arrow-top-right-on-square"
class="size-4"
/>
</ULink>
</template>
</CardField>
</div>
</UCard>
</UContainer>
<template #value>
<ULink
:href="stravaLink"
class="underline flex items-center gap-2"
>
{{ user.id }}
<UIcon
name="heroicons:arrow-top-right-on-square"
class="size-4"
/>
</ULink>
</template>
</CardField>
</div>
</UCard>
</UContainer>
</template>

View File

@@ -1,20 +0,0 @@
import posthog from "posthog-js";
export default defineNuxtPlugin(() => {
const runtimeConfig = useRuntimeConfig();
const posthogClient = posthog.init(runtimeConfig.public.posthogPublicKey, {
api_host: runtimeConfig.public.posthogHost,
//@ts-expect-error typing is more explicit than what it should
defaults: runtimeConfig.public.posthogDefaults,
loaded: (posthog) => {
if (import.meta.env.MODE === "development") posthog.debug();
},
});
return {
provide: {
posthog: () => posthogClient,
},
};
});

9111
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
- core-js
- esbuild
- sharp
- vue-demi

View File

@@ -5,13 +5,13 @@ import {
availableTones,
availableUnits,
} from "~/shared/constants";
//
const bodySchema = z.strictObject({
enabled: z.boolean(),
language: z.enum(availableLanguages),
units: z.enum(availableUnits),
tone: z.array(z.enum(availableTones)),
highlights: z.array(z.enum(availableHighlights)),
units: z.enum(availableUnits).default(availableUnits[1]),
tone: z.array(z.enum(availableTones)).default([]),
highlights: z.array(z.enum(availableHighlights)).default([]),
});
export default defineEventHandler(async (event) => {

View File

@@ -1,11 +1,8 @@
import { get } from "radash";
import { createActivityContent } from "~~/server/utils/create-content";
export default defineEventHandler(async (event) => {
await validateHookdeck(event);
const posthog = event.context.posthog;
const body = await readBody(event);
const db = useDrizzle();
@@ -25,7 +22,7 @@ export default defineEventHandler(async (event) => {
const currentActivity = await strava!<any>(`/activities/${body.object_id}`);
const [, ...previousActivities] = await strava!<any[]>(`/activities`, {
query: {
per_page: 20,
per_page: 10,
},
});
@@ -44,8 +41,11 @@ export default defineEventHandler(async (event) => {
await strava!(`activities/${body.object_id}`, {
method: "PUT",
body: {
name: stravaRequestBody.name,
description: stravaRequestBody.description,
name: (stravaRequestBody.name as String).replaceAll("—", ","),
description: (stravaRequestBody.description as String).replaceAll(
"—",
",",
),
},
}).catch((error) => {
throw createError({
@@ -53,15 +53,4 @@ export default defineEventHandler(async (event) => {
message: `Strava API: ${error.message}`,
});
});
posthog.captureImmediate({
distinctId: String(user.id),
event: "content generated",
properties: {
activity: currentActivity.id,
activityType: get(currentActivity, "sport_type", "unknown"),
highlight: stravaRequestBody.meta.highlight,
tone: stravaRequestBody.meta.tone,
},
});
});

View File

@@ -4,8 +4,6 @@ import { eq } from "drizzle-orm";
export default defineEventHandler(async (event) => {
await validateHookdeck(event);
const posthog = event.context.posthog;
const body = await readBody(event);
const db = useDrizzle();
@@ -13,29 +11,9 @@ export default defineEventHandler(async (event) => {
return;
}
const user = await db.query.users.findFirst({
where: (f, o) => o.eq(f.id, get(body, "object_id")),
with: {
preferences: true,
},
});
posthog.identifyImmediate({
distinctId: String(user!.id),
properties: {
name: user!.name,
country: user!.country,
},
});
await db
.delete(tables.users)
.where(eq(tables.users.id, get(body, "object_id")));
posthog.captureImmediate({
distinctId: get(body, "object_id"),
event: "user deleted",
});
sendNoContent(event);
});

View File

@@ -0,0 +1,28 @@
CREATE TABLE `preferences` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` numeric,
`data` text,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `preferences_user_id_unique` ON `preferences` (`user_id`);--> statement-breakpoint
CREATE TABLE `tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` numeric,
`refresh_token` text,
`access_token` text,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tokens_user_id_unique` ON `tokens` (`user_id`);--> statement-breakpoint
CREATE TABLE `users` (
`id` numeric PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`avatar` text,
`city` text,
`country` text,
`sex` text,
`weight` integer,
`created_at` integer
);

View File

@@ -1,29 +0,0 @@
CREATE TABLE "preferences" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "preferences_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer,
"data" jsonb,
CONSTRAINT "preferences_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "tokens" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "tokens_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer,
"refresh_token" text,
"access_token" text,
"expires_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "tokens_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" integer PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"avatar" text NOT NULL,
"city" text,
"country" text,
"sex" text,
"weight" numeric,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "preferences" ADD CONSTRAINT "preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tokens" ADD CONSTRAINT "tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -1 +0,0 @@
ALTER TABLE "users" ADD COLUMN "premium" boolean;

View File

@@ -1 +0,0 @@
ALTER TABLE "users" ALTER COLUMN "premium" SET NOT NULL;

View File

@@ -1,210 +1,204 @@
{
"id": "c8519a52-b999-48f3-b532-42a5678e3905",
"version": "6",
"dialect": "sqlite",
"id": "347ea848-a216-4fff-910a-90ba2f9aa91c",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.preferences": {
"preferences": {
"name": "preferences",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "preferences_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"type": "numeric",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"data": {
"name": "data",
"type": "jsonb",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"preferences_user_id_unique": {
"name": "preferences_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"indexes": {},
"foreignKeys": {
"preferences_user_id_users_id_fk": {
"name": "preferences_user_id_users_id_fk",
"tableFrom": "preferences",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"preferences_user_id_unique": {
"name": "preferences_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"uniqueConstraints": {},
"checkConstraints": {}
},
"public.tokens": {
"tokens": {
"name": "tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tokens_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"type": "numeric",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": "now()"
"autoincrement": false
}
},
"indexes": {
"tokens_user_id_unique": {
"name": "tokens_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"indexes": {},
"foreignKeys": {
"tokens_user_id_users_id_fk": {
"name": "tokens_user_id_users_id_fk",
"tableFrom": "tokens",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tokens_user_id_unique": {
"name": "tokens_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"uniqueConstraints": {},
"checkConstraints": {}
},
"public.users": {
"users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"type": "numeric",
"primaryKey": true,
"notNull": true
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": false,
"autoincrement": false
},
"city": {
"name": "city",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"country": {
"name": "country",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"sex": {
"name": "sex",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"weight": {
"name": "weight",
"type": "numeric",
"type": "integer",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": "now()"
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"checkConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"enums": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,216 +0,0 @@
{
"id": "912c6e36-57b1-4e7f-a29b-586b187b1c32",
"prevId": "c8519a52-b999-48f3-b532-42a5678e3905",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.preferences": {
"name": "preferences",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "preferences_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preferences_user_id_users_id_fk": {
"name": "preferences_user_id_users_id_fk",
"tableFrom": "preferences",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"preferences_user_id_unique": {
"name": "preferences_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tokens": {
"name": "tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tokens_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"tokens_user_id_users_id_fk": {
"name": "tokens_user_id_users_id_fk",
"tableFrom": "tokens",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tokens_user_id_unique": {
"name": "tokens_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"city": {
"name": "city",
"type": "text",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sex": {
"name": "sex",
"type": "text",
"primaryKey": false,
"notNull": false
},
"premium": {
"name": "premium",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"weight": {
"name": "weight",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,216 +0,0 @@
{
"id": "7b607167-551b-4aa3-8305-afe3c4d72ace",
"prevId": "912c6e36-57b1-4e7f-a29b-586b187b1c32",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.preferences": {
"name": "preferences",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "preferences_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preferences_user_id_users_id_fk": {
"name": "preferences_user_id_users_id_fk",
"tableFrom": "preferences",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"preferences_user_id_unique": {
"name": "preferences_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tokens": {
"name": "tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tokens_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"tokens_user_id_users_id_fk": {
"name": "tokens_user_id_users_id_fk",
"tableFrom": "tokens",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tokens_user_id_unique": {
"name": "tokens_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"city": {
"name": "city",
"type": "text",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sex": {
"name": "sex",
"type": "text",
"primaryKey": false,
"notNull": false
},
"premium": {
"name": "premium",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"weight": {
"name": "weight",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,26 +1,12 @@
{
"version": "7",
"dialect": "postgresql",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1745335305323,
"tag": "0000_slim_blonde_phantom",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1747547391071,
"tag": "0001_smooth_jazinda",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1747564780270,
"tag": "0002_many_marauders",
"version": "6",
"when": 1772879556762,
"tag": "0000_groovy_rachel_grey",
"breakpoints": true
}
]

View File

@@ -1,34 +1,27 @@
import { relations } from "drizzle-orm";
import {
pgTable,
text,
integer,
numeric,
timestamp,
jsonb,
} from "drizzle-orm/pg-core";
import { sqliteTable, text, integer, numeric } from "drizzle-orm/sqlite-core";
export const users = pgTable("users", {
id: integer("id").primaryKey(),
export const users = sqliteTable("users", {
id: numeric("id").primaryKey(),
name: text("name").notNull(),
avatar: text("avatar").notNull(),
avatar: text("avatar"),
city: text("city"),
country: text("country"),
sex: text("sex"),
weight: numeric("weight", {
mode: "number",
}),
createdAt: timestamp("created_at").notNull().defaultNow(),
weight: integer("weight"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
});
export const preferences = pgTable("preferences", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
userId: integer("user_id")
export const preferences = sqliteTable("preferences", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: numeric("user_id")
.references(() => users.id, {
onDelete: "cascade",
})
.unique(),
data: jsonb("data")
data: text("data", { mode: "json" })
.$type<{
enabled: boolean;
language: string;
@@ -45,19 +38,20 @@ export const preferences = pgTable("preferences", {
})),
});
export const tokens = pgTable("tokens", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
userId: integer("user_id")
export const tokens = sqliteTable("tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: numeric("user_id")
.references(() => users.id, {
onDelete: "cascade",
})
.unique(),
refreshToken: text("refresh_token"),
accessToken: text("access_token"),
expiresAt: timestamp("expires_at").notNull().defaultNow(),
expiresAt: integer("expires_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
// Define relationships
export const usersRelations = relations(users, ({ one }) => ({
tokens: one(tokens, {
fields: [users.id],
@@ -69,7 +63,7 @@ export const usersRelations = relations(users, ({ one }) => ({
}),
}));
export const referencesRelations = relations(preferences, ({ one }) => ({
export const preferencesRelations = relations(preferences, ({ one }) => ({
user: one(users, {
fields: [preferences.userId],
references: [users.id],

View File

@@ -0,0 +1,88 @@
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import Database from "better-sqlite3";
import { users, tokens, preferences } from "../database/schema";
import { existsSync, readFileSync } from "node:fs";
export default defineNitroPlugin(async () => {
const db = useDrizzle();
migrate(db, { migrationsFolder: "./server/database/migrations" });
console.log("Database migrations applied");
const feedPath = "./tmp/feed.json";
if (existsSync(feedPath)) {
const feed = JSON.parse(readFileSync(feedPath, "utf-8"));
console.log(`Importing ${feed.length} users from feed.json`);
for (const entry of feed) {
const userId = String(entry.user.id);
const createdAt =
entry.user.created_at && entry.user.created_at !== "\r"
? new Date(entry.user.created_at)
: new Date();
const userData = {
id: userId,
name: entry.user.name || "Unknown",
avatar: entry.user.avatar || null,
city: entry.user.city || null,
country:
typeof entry.user.country === "string" && entry.user.country !== "\r"
? entry.user.country
: null,
sex:
typeof entry.user.sex === "string" && entry.user.sex !== "\r"
? entry.user.sex
: null,
weight: Number(entry.user.weight) || null,
createdAt,
};
await db
.insert(users)
.values(userData)
.onConflictDoUpdate({
target: users.id,
set: { ...userData },
});
const tokenData = {
userId: userId,
refreshToken: entry.token.refresh_token || null,
accessToken: entry.token.access_token || null,
expiresAt: new Date(entry.token.expires_at),
};
await db
.insert(tokens)
.values(tokenData)
.onConflictDoUpdate({
target: tokens.userId,
set: { ...tokenData },
});
const prefsData = {
userId,
data: {
enabled: entry.preferences.enabled ?? true,
language: entry.preferences.language || "English",
units: entry.preferences.units || "Metric",
tone: entry.preferences.tone || [],
highlights: entry.preferences.highlights || [],
},
};
await db
.insert(preferences)
.values(prefsData)
.onConflictDoUpdate({
target: preferences.userId,
set: { ...prefsData },
});
}
console.log(`Successfully imported ${feed.length} users`);
}
});

View File

@@ -1,29 +0,0 @@
import { PostHog } from "posthog-node";
import { waitUntil } from "@vercel/functions";
export default defineNitroPlugin((nitroApp) => {
const runtimeConfig = useRuntimeConfig();
const posthog = new PostHog(runtimeConfig.public.posthogPublicKey, {
host: runtimeConfig.public.posthogHost,
flushAt: 1,
flushInterval: 0,
});
nitroApp.hooks.hook("request", (event) => {
event.context.posthog = posthog;
});
nitroApp.hooks.hook("beforeResponse", () => {
waitUntil(posthog.shutdown());
});
nitroApp.hooks.hook("close", () => {
waitUntil(posthog.shutdown());
});
});
declare module "h3" {
interface H3EventContext {
posthog: PostHog;
}
}

View File

@@ -24,15 +24,13 @@ export default defineOAuthStravaEventHandler({
});
}
const posthog = event.context.posthog;
const userPayload = {
id: auth.user.id,
name: `${auth.user.firstname} ${auth.user.lastname}`,
city: auth.user.city,
country: auth.user.country,
sex: auth.user.sex,
weight: auth.user.weight,
// weight: auth.user.weight,
avatar: auth.user.profile,
};
@@ -82,19 +80,6 @@ export default defineOAuthStravaEventHandler({
user: userPayload,
});
posthog.identifyImmediate({
distinctId: String(user!.id),
properties: {
name: user!.name,
country: user!.country,
},
});
posthog.captureImmediate({
distinctId: String(user!.id),
event: "user logged in",
});
sendRedirect(event, "/");
},
});

View File

@@ -99,49 +99,18 @@ export const createActivityContent = async ({
const highlight = isEmpty(user.preferences.data?.highlights)
? (draw(availableHighlights) as string)
: draw(user.preferences.data!.highlights!);
const highlightInstructions = match({ highlight, activity: currentActivity })
.when(
({ highlight, activity }) =>
highlight === "Area Exploration" &&
movingActivityTypes.includes(get(activity, "type")),
() =>
"Focus on places visited and areas explored. Highlight any previous or new visits.",
)
.with(
{ highlight: "Athletic" },
() =>
"Highlight athletic properties and performance. Highlight PR's as well but only if available.",
)
.with(
{ highlight: "Mood" },
() =>
"Focus on how mood was swinging through the activity, ie I was feeling exhausted because of climb, I was feeling super happy on that descent!",
)
.with({ highlight: "Conditions" }, () => "Highlight on weather conditions")
.otherwise(() => "");
const length = match({ tone })
.with({ tone: "Minimalist" }, () => "short")
.otherwise(() => draw(["short", "medium", "a-little-more-than-medium"]));
.with({ tone: "Minimalist" }, () => "very short")
.otherwise(() => draw(["very short", "short"]));
const prompt = `
Generate a short title and a ${length}-lengthed description for my strava activity. Use my preferred language and unit system.
Use first person, as this will be posting for myself. Try to not exaggerate as I am using Strava often and I want my activites to be unique and easy to read. Don't use repeative language.
Use a little bit of ${tone} tone to make things less boring.
${highlightInstructions}
Maybe comment if any interesting fact in comparison to previous activities.
Add #${tone} and #${highlight} at the end of the description. Depending the length of the description, maybe add more hashtags.
Language: ${user?.preferences.data!.language}
Unit system: ${user?.preferences.data!.units}
Activity notes:
Distance is in meters, time is in seconds, don't include average speed.
Convert time to hours or minutes, whatever's closer.
Convert distance to larger units when appropriate, we don't need accuracy. Better say almost 50 instead of 48.67 for example.
In the end of the description, add "${promo}" translated to my language.
Tone: ${tone}
Highlight: ${highlight}
Description length: ${length}
The activity data in json format from strava:
${stringifyActivity({ activity: currentActivity })}
@@ -151,8 +120,11 @@ export const createActivityContent = async ({
`;
const aiResponse = await openai.responses.create({
model: "gpt-5-mini",
model: "@preset/ghostwriter",
input: [{ role: "user", content: prompt }],
reasoning: {
effort: "minimal",
},
text: {
format: {
type: "json_schema",
@@ -184,10 +156,6 @@ export const createActivityContent = async ({
const stravaRequestBody = {
name: responseObject!.title,
description: responseObject!.description,
meta: {
highlight,
tone,
},
};
return [parseError, stravaRequestBody] as const;

View File

@@ -1,6 +1,4 @@
import { drizzle } from "drizzle-orm/neon-http";
export { sql, eq, and, or } from "drizzle-orm";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "../database/schema";
export const tables = schema;

View File

@@ -4,7 +4,8 @@ export const useOpenAI = () => {
const config = useRuntimeConfig();
const client = new OpenAI({
apiKey: config.openaiApiKey,
apiKey: config.openrouterApiKey,
baseURL: "https://openrouter.ai/api/v1",
});
return client;

View File

@@ -1,20 +0,0 @@
import { PostHog } from "posthog-node";
let client: PostHog;
export const usePosthog = () => {
const runtimeConfig = useRuntimeConfig();
client =
client ??
new PostHog(runtimeConfig.public.posthogPublicKey, {
host: runtimeConfig.public.posthogHost,
defaults: runtimeConfig.public.posthogDefaults,
});
if (process.dev) {
client.debug();
}
return client;
};