Compare commits

...

10 Commits

Author SHA1 Message Date
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
39 changed files with 3595 additions and 27687 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitignore
.nuxt
.output
node_modules
*.md
README*
.env
.env.*
!.env.example

View File

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

2
.gitignore vendored
View File

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

1
.npmrc Normal file
View File

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

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM oven/bun:1-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nuxt
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/package.json ./
USER nuxt
EXPOSE 3000
CMD ["bun", "x", "nuxt", "start"]

View File

@@ -2,24 +2,47 @@
## Strava Activity Title & Description Generator ## 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 ```bash
git clone [repository_url] cp .env.example .env
``` ```
2. Navigate to the project directory:
2. Install and run:
```bash ```bash
cd ghostwriter bun install
bun run dev
``` ```
3. Install dependencies:
```bash Open http://localhost:3000 and start ghostwriting those activities into legend.
npm install
``` ## Commands
4. Run the development server:
```bash | Command | What it does |
npm run dev |---------|--------------|
``` | `bun run dev` | Spin up the dev server |
5. Access the application at http://localhost:3000 | `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
bun run dev
# Build
bun run build
# Generate static site
bun run generate
# Type checking
bunx vue-tsc --noEmit
# Database
bunx drizzle-kit push
bunx drizzle-kit studio
```
## Project Info
- Nuxt 4
- Uses bun as package manager and runtime
- Database: Drizzle ORM with Bun's sqlite
- UI: @nuxt/ui v3

2850
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,9 @@ const { openInPopup } = useUserSession();
<UCard class="max-w-sm grid gap-6 justify-center items-center"> <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 flex-col gap-10 items-center justify-center">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<div class="font-bold text-xl tracking-tight font-fira-code"> <div
class="font-bold text-xl tracking-tight font-fira-code"
>
Ghostwriter Ghostwriter
</div> </div>
<NuxtImg src="/ghostwriter-logo.png" class="size-9" /> <NuxtImg src="/ghostwriter-logo.png" class="size-9" />
@@ -23,8 +25,8 @@ const { openInPopup } = useUserSession();
Sign in to your account. Sign in to your account.
</div> </div>
<div class="text-center"> <div class="text-center">
Connect with Strava to automatically add personalized titles and Connect with Strava to automatically add personalized
descriptions to your activities. titles and descriptions to your activities.
</div> </div>
</div> </div>
<div <div

View File

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

View File

@@ -12,13 +12,8 @@ export default defineNuxtConfig({
webhooksUrl: "", webhooksUrl: "",
stravaVerifyToken: "", stravaVerifyToken: "",
hookdeckKey: "", hookdeckKey: "",
openaiApiKey: "", openrouterApiKey: "",
databaseUrl: "", databaseUrl: "",
public: {
posthogPublicKey: "",
posthogHost: "",
posthogDefaults: "2025-05-24",
},
}, },
future: { compatibilityVersion: 4 }, future: { compatibilityVersion: 4 },
compatibilityDate: "2025-03-01", compatibilityDate: "2025-03-01",

16152
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,21 +10,16 @@
}, },
"dependencies": { "dependencies": {
"@formkit/tempo": "^0.1.2", "@formkit/tempo": "^0.1.2",
"@google/genai": "^1.13.0",
"@neondatabase/serverless": "^1.0.1",
"@nuxt/icon": "1.15.0", "@nuxt/icon": "1.15.0",
"@nuxt/image": "^1.11.0", "@nuxt/image": "^1.11.0",
"@nuxt/ui": "3.3.0", "@nuxt/ui": "3.3.0",
"@vee-validate/nuxt": "^4.15.1", "@vee-validate/nuxt": "^4.15.1",
"@vercel/functions": "^2.2.8",
"@vueuse/nuxt": "^13.6.0", "@vueuse/nuxt": "^13.6.0",
"destr": "^2.0.5", "destr": "^2.0.5",
"drizzle-orm": "^0.44.4", "drizzle-orm": "^0.44.4",
"nuxt": "^4.0.3", "nuxt": "^4.0.3",
"nuxt-auth-utils": "0.5.23", "nuxt-auth-utils": "0.5.23",
"openai": "^5.12.2", "openai": "^5.12.2",
"posthog-js": "^1.259.0",
"posthog-node": "^5.6.0",
"radash": "^12.1.1", "radash": "^12.1.1",
"ts-pattern": "^5.8.0", "ts-pattern": "^5.8.0",
"url": "^0.11.4", "url": "^0.11.4",
@@ -33,6 +28,9 @@
"zod": "^4.0.16" "zod": "^4.0.16"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/heroicons": "^1.2.3",
"@iconify-json/lucide": "^1.2.95",
"@types/bun": "^1.3.10",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vue-tsc": "^3.0.5" "vue-tsc": "^3.0.5"

View File

@@ -54,14 +54,14 @@ const saveOp = watchPausable(
<div class="font-bold text-lg">Welcome to Ghostwriter!</div> <div class="font-bold text-lg">Welcome to Ghostwriter!</div>
<div> <div>
Let's generate fun and engaging titles and descriptions for your Strava Let's generate fun and engaging titles and descriptions for your
activities automatically, right when they are created. Customize your Strava activities automatically, right when they are created.
preferences below. Customize your preferences below.
</div> </div>
<div> <div>
Add a touch of creativity to your Strava workouts. Simply enable it and Add a touch of creativity to your Strava workouts. Simply enable it
choose your language, and we'll do the rest! and choose your language, and we'll do the rest!
</div> </div>
</UContainer> </UContainer>
@@ -69,12 +69,15 @@ const saveOp = watchPausable(
<div class="font-bold text-lg"> Support</div> <div class="font-bold text-lg"> Support</div>
<UCard class=""> <UCard class="">
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
Ghostwriter 👻 is free to use, but it takes time and resources to keep Ghostwriter 👻 is free to use, but it takes time and resources
it running smoothly. If you enjoy it, consider supporting the app and to keep it running smoothly. If you enjoy it, consider
its creator - every bit helps! supporting the app and its creator - every bit helps!
</div> </div>
<template #footer> <template #footer>
<ULink href="https://buymeacoffee.com/mariosant" target="_blank"> <ULink
href="https://buymeacoffee.com/mariosant"
target="_blank"
>
<NuxtImg <NuxtImg
src="images/bmac-orange-button.png" src="images/bmac-orange-button.png"
height="32px" height="32px"
@@ -190,11 +193,15 @@ const saveOp = watchPausable(
<CardField> <CardField>
<template #title> Athlete ID </template> <template #title> Athlete ID </template>
<template #description> <template #description>
Your Athlete ID. Click it to view your profile on Strava.</template Your Athlete ID. Click it to view your profile on
Strava.</template
> >
<template #value> <template #value>
<ULink :href="stravaLink" class="underline flex items-center gap-2"> <ULink
:href="stravaLink"
class="underline flex items-center gap-2"
>
{{ user.id }} {{ user.id }}
<UIcon <UIcon
name="heroicons:arrow-top-right-on-square" name="heroicons:arrow-top-right-on-square"

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,
},
};
});

10627
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, availableTones,
availableUnits, availableUnits,
} from "~/shared/constants"; } from "~/shared/constants";
//
const bodySchema = z.strictObject({ const bodySchema = z.strictObject({
enabled: z.boolean(), enabled: z.boolean(),
language: z.enum(availableLanguages), language: z.enum(availableLanguages),
units: z.enum(availableUnits), units: z.enum(availableUnits).default(availableUnits[1]),
tone: z.array(z.enum(availableTones)), tone: z.array(z.enum(availableTones)).default([]),
highlights: z.array(z.enum(availableHighlights)), highlights: z.array(z.enum(availableHighlights)).default([]),
}); });
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {

View File

@@ -1,11 +1,8 @@
import { get } from "radash";
import { createActivityContent } from "~~/server/utils/create-content"; import { createActivityContent } from "~~/server/utils/create-content";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await validateHookdeck(event); await validateHookdeck(event);
const posthog = event.context.posthog;
const body = await readBody(event); const body = await readBody(event);
const db = useDrizzle(); const db = useDrizzle();
@@ -25,7 +22,7 @@ export default defineEventHandler(async (event) => {
const currentActivity = await strava!<any>(`/activities/${body.object_id}`); const currentActivity = await strava!<any>(`/activities/${body.object_id}`);
const [, ...previousActivities] = await strava!<any[]>(`/activities`, { const [, ...previousActivities] = await strava!<any[]>(`/activities`, {
query: { query: {
per_page: 20, per_page: 10,
}, },
}); });
@@ -44,8 +41,11 @@ export default defineEventHandler(async (event) => {
await strava!(`activities/${body.object_id}`, { await strava!(`activities/${body.object_id}`, {
method: "PUT", method: "PUT",
body: { body: {
name: stravaRequestBody.name, name: (stravaRequestBody.name as String).replaceAll("—", ","),
description: stravaRequestBody.description, description: (stravaRequestBody.description as String).replaceAll(
"—",
",",
),
}, },
}).catch((error) => { }).catch((error) => {
throw createError({ throw createError({
@@ -53,15 +53,4 @@ export default defineEventHandler(async (event) => {
message: `Strava API: ${error.message}`, 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) => { export default defineEventHandler(async (event) => {
await validateHookdeck(event); await validateHookdeck(event);
const posthog = event.context.posthog;
const body = await readBody(event); const body = await readBody(event);
const db = useDrizzle(); const db = useDrizzle();
@@ -13,29 +11,9 @@ export default defineEventHandler(async (event) => {
return; 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 await db
.delete(tables.users) .delete(tables.users)
.where(eq(tables.users.id, get(body, "object_id"))); .where(eq(tables.users.id, get(body, "object_id")));
posthog.captureImmediate({
distinctId: get(body, "object_id"),
event: "user deleted",
});
sendNoContent(event); 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 NOT NULL
);

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

@@ -0,0 +1,16 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_users` (
`id` numeric PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`avatar` text,
`city` text,
`country` text,
`sex` text,
`weight` integer,
`created_at` integer
);
--> statement-breakpoint
INSERT INTO `__new_users`("id", "name", "avatar", "city", "country", "sex", "weight", "created_at") SELECT "id", "name", "avatar", "city", "country", "sex", "weight", "created_at" FROM `users`;--> statement-breakpoint
DROP TABLE `users`;--> statement-breakpoint
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

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": "d9537942-cba2-4af4-8396-533366512937",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": { "tables": {
"public.preferences": { "preferences": {
"name": "preferences", "name": "preferences",
"schema": "",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
"type": "integer", "type": "integer",
"primaryKey": true, "primaryKey": true,
"notNull": true, "notNull": true,
"identity": { "autoincrement": true
"type": "always",
"name": "preferences_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
}, },
"user_id": { "user_id": {
"name": "user_id", "name": "user_id",
"type": "integer", "type": "numeric",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false,
"autoincrement": false
}, },
"data": { "data": {
"name": "data", "name": "data",
"type": "jsonb", "type": "text",
"primaryKey": false, "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": { "foreignKeys": {
"preferences_user_id_users_id_fk": { "preferences_user_id_users_id_fk": {
"name": "preferences_user_id_users_id_fk", "name": "preferences_user_id_users_id_fk",
"tableFrom": "preferences", "tableFrom": "preferences",
"tableTo": "users", "tableTo": "users",
"columnsFrom": ["user_id"], "columnsFrom": [
"columnsTo": ["id"], "user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": { "uniqueConstraints": {},
"preferences_user_id_unique": { "checkConstraints": {}
"name": "preferences_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
}, },
"policies": {}, "tokens": {
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tokens": {
"name": "tokens", "name": "tokens",
"schema": "",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
"type": "integer", "type": "integer",
"primaryKey": true, "primaryKey": true,
"notNull": true, "notNull": true,
"identity": { "autoincrement": true
"type": "always",
"name": "tokens_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
}, },
"user_id": { "user_id": {
"name": "user_id", "name": "user_id",
"type": "integer", "type": "numeric",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false,
"autoincrement": false
}, },
"refresh_token": { "refresh_token": {
"name": "refresh_token", "name": "refresh_token",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false,
"autoincrement": false
}, },
"access_token": { "access_token": {
"name": "access_token", "name": "access_token",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false,
"autoincrement": false
}, },
"expires_at": { "expires_at": {
"name": "expires_at", "name": "expires_at",
"type": "timestamp", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"default": "now()" "autoincrement": false
}
},
"indexes": {
"tokens_user_id_unique": {
"name": "tokens_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
} }
}, },
"indexes": {},
"foreignKeys": { "foreignKeys": {
"tokens_user_id_users_id_fk": { "tokens_user_id_users_id_fk": {
"name": "tokens_user_id_users_id_fk", "name": "tokens_user_id_users_id_fk",
"tableFrom": "tokens", "tableFrom": "tokens",
"tableTo": "users", "tableTo": "users",
"columnsFrom": ["user_id"], "columnsFrom": [
"columnsTo": ["id"], "user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": { "uniqueConstraints": {},
"tokens_user_id_unique": { "checkConstraints": {}
"name": "tokens_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
}, },
"policies": {}, "users": {
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users", "name": "users",
"schema": "",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
"type": "integer", "type": "numeric",
"primaryKey": true, "primaryKey": true,
"notNull": true "notNull": true,
"autoincrement": false
}, },
"name": { "name": {
"name": "name", "name": "name",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true,
"autoincrement": false
}, },
"avatar": { "avatar": {
"name": "avatar", "name": "avatar",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": false,
"autoincrement": false
}, },
"city": { "city": {
"name": "city", "name": "city",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false,
"autoincrement": false
}, },
"country": { "country": {
"name": "country", "name": "country",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false,
"autoincrement": false
}, },
"sex": { "sex": {
"name": "sex", "name": "sex",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false,
"autoincrement": false
}, },
"weight": { "weight": {
"name": "weight", "name": "weight",
"type": "numeric", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false,
"autoincrement": false
}, },
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamp", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"default": "now()" "autoincrement": false
} }
}, },
"indexes": {}, "indexes": {},
"foreignKeys": {}, "foreignKeys": {},
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {}, "uniqueConstraints": {},
"policies": {}, "checkConstraints": {}
"checkConstraints": {},
"isRLSEnabled": false
} }
}, },
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {}, "views": {},
"enums": {},
"_meta": { "_meta": {
"columns": {},
"schemas": {}, "schemas": {},
"tables": {} "tables": {},
"columns": {}
},
"internal": {
"indexes": {}
} }
} }

View File

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

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,19 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "sqlite",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "6",
"when": 1745335305323, "when": 1772727834477,
"tag": "0000_slim_blonde_phantom", "tag": "0000_living_oracle",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "6",
"when": 1747547391071, "when": 1772816922744,
"tag": "0001_smooth_jazinda", "tag": "0001_round_husk",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1747564780270,
"tag": "0002_many_marauders",
"breakpoints": true "breakpoints": true
} }
] ]

View File

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

116
server/plugins/feed.ts Normal file
View File

@@ -0,0 +1,116 @@
import { drizzle } from "drizzle-orm/bun-sqlite";
import { existsSync } from "node:fs";
import { readFileSync } from "node:fs";
import * as schema from "../database/schema";
interface FeedItem {
user: {
id: number;
name: string;
avatar: string | null;
city: string | null;
country: string | null;
sex: string | null;
weight: number | null;
created_at: string;
};
token: {
id: number;
user_id: number;
refresh_token: string;
access_token: string;
expires_at: string;
};
preferences: {
tone: string[];
units: "Imperial" | "Metric";
enabled: boolean;
language: string;
};
}
export default defineNitroPlugin(async () => {
const config = useRuntimeConfig();
const feedPath = "tmp/feed.json";
if (!existsSync(feedPath)) {
return;
}
console.log("Processing feed.json...");
const db = drizzle(config.databaseUrl, { schema });
const feed: FeedItem[] = JSON.parse(readFileSync(feedPath, "utf-8"));
for (const item of feed) {
const userId = String(item.user.id);
await db
.insert(schema.users)
.values({
id: userId,
name: item.user.name,
avatar: item.user.avatar,
city: item.user.city,
country: item.user.country,
sex: item.user.sex,
weight: item.user.weight,
createdAt: new Date(item.user.created_at),
})
.onConflictDoUpdate({
target: schema.users.id,
set: {
name: item.user.name,
avatar: item.user.avatar,
city: item.user.city,
country: item.user.country,
sex: item.user.sex,
weight: item.user.weight,
},
});
await db
.insert(schema.tokens)
.values({
userId: userId,
refreshToken: item.token.refresh_token,
accessToken: item.token.access_token,
expiresAt: new Date(item.token.expires_at),
})
.onConflictDoUpdate({
target: schema.tokens.userId,
set: {
refreshToken: item.token.refresh_token,
accessToken: item.token.access_token,
expiresAt: new Date(item.token.expires_at),
},
});
await db
.insert(schema.preferences)
.values({
userId: userId,
data: {
enabled: item.preferences.enabled,
language: item.preferences.language,
units: item.preferences.units,
tone: item.preferences.tone,
highlights: [],
},
})
.onConflictDoUpdate({
target: schema.preferences.userId,
set: {
data: {
enabled: item.preferences.enabled,
language: item.preferences.language,
units: item.preferences.units,
tone: item.preferences.tone,
highlights: [],
},
},
});
}
console.log(`Processed ${feed.length} feed items`);
});

View File

@@ -0,0 +1,12 @@
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "../database/schema";
export default defineNitroPlugin(async () => {
const config = useRuntimeConfig();
const db = drizzle(config.databaseUrl, { schema });
console.log("Running database migrations...");
await migrate(db, { migrationsFolder: "./server/database/migrations" });
console.log("Database migrations complete");
});

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 = { const userPayload = {
id: auth.user.id, id: auth.user.id,
name: `${auth.user.firstname} ${auth.user.lastname}`, name: `${auth.user.firstname} ${auth.user.lastname}`,
city: auth.user.city, city: auth.user.city,
country: auth.user.country, country: auth.user.country,
sex: auth.user.sex, sex: auth.user.sex,
weight: auth.user.weight, // weight: auth.user.weight,
avatar: auth.user.profile, avatar: auth.user.profile,
}; };
@@ -82,19 +80,6 @@ export default defineOAuthStravaEventHandler({
user: userPayload, 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, "/"); sendRedirect(event, "/");
}, },
}); });

View File

@@ -99,49 +99,18 @@ export const createActivityContent = async ({
const highlight = isEmpty(user.preferences.data?.highlights) const highlight = isEmpty(user.preferences.data?.highlights)
? (draw(availableHighlights) as string) ? (draw(availableHighlights) as string)
: draw(user.preferences.data!.highlights!); : 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 }) const length = match({ tone })
.with({ tone: "Minimalist" }, () => "short") .with({ tone: "Minimalist" }, () => "very short")
.otherwise(() => draw(["short", "medium", "a-little-more-than-medium"])); .otherwise(() => draw(["very short", "short"]));
const prompt = ` 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} Language: ${user?.preferences.data!.language}
Unit system: ${user?.preferences.data!.units} Unit system: ${user?.preferences.data!.units}
Activity notes: Tone: ${tone}
Distance is in meters, time is in seconds, don't include average speed. Highlight: ${highlight}
Convert time to hours or minutes, whatever's closer. Description length: ${length}
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.
The activity data in json format from strava: The activity data in json format from strava:
${stringifyActivity({ activity: currentActivity })} ${stringifyActivity({ activity: currentActivity })}
@@ -151,8 +120,11 @@ export const createActivityContent = async ({
`; `;
const aiResponse = await openai.responses.create({ const aiResponse = await openai.responses.create({
model: "gpt-5-mini", model: "@preset/ghostwriter",
input: [{ role: "user", content: prompt }], input: [{ role: "user", content: prompt }],
reasoning: {
effort: "minimal",
},
text: { text: {
format: { format: {
type: "json_schema", type: "json_schema",
@@ -184,10 +156,6 @@ export const createActivityContent = async ({
const stravaRequestBody = { const stravaRequestBody = {
name: responseObject!.title, name: responseObject!.title,
description: responseObject!.description, description: responseObject!.description,
meta: {
highlight,
tone,
},
}; };
return [parseError, stravaRequestBody] as const; return [parseError, stravaRequestBody] as const;

View File

@@ -1,5 +1,10 @@
import { drizzle } from "drizzle-orm/neon-http"; import { drizzle } from "drizzle-orm/bun-sqlite";
export { sql, eq, and, or } from "drizzle-orm"; import { eq as _eq, and as _and, or as _or, sql as _sql } from "drizzle-orm";
export const sql = _sql;
export const eq = _eq;
export const and = _and;
export const or = _or;
import * as schema from "../database/schema"; import * as schema from "../database/schema";

View File

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