Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ad10dfa
chore: create a plan
ccssmnn Jan 25, 2026
4bee727
chore: change push notification logic to not rely on clerk
ccssmnn Jan 25, 2026
83c9e81
fix: address review issues in push notification refactor
ccssmnn Jan 25, 2026
ab68fda
feat: dynamic stale threshold based on future reminders
ccssmnn Jan 25, 2026
ed0c0f9
chore: add tests
ccssmnn Jan 27, 2026
4c472bb
chore: improve testability
ccssmnn Jan 30, 2026
e999d97
chore: address minor issues
ccssmnn Jan 30, 2026
1dec5eb
chore: write ATPs
ccssmnn Jan 30, 2026
a257c34
Merge branch 'main' into ccssmnn/push-notifications-without-clerk
ccssmnn Jan 30, 2026
5c03912
fix: address coderabbit findings
ccssmnn Jan 30, 2026
ba0a413
fix: address nitpick changes
ccssmnn Jan 30, 2026
e052ada
fix: address minor issues
ccssmnn Jan 30, 2026
df27bad
feat: add CI and don't run checks on vercel builds
ccssmnn Jan 30, 2026
779f9af
fix: address findings
ccssmnn Jan 30, 2026
655e87e
fix: tests
ccssmnn Jan 30, 2026
f70ed20
fix: tests
ccssmnn Jan 30, 2026
7a6256b
chore: test push notification logic, harmonize logs
ccssmnn Jan 31, 2026
359ef78
chore: final review and fixes
ccssmnn Feb 2, 2026
70aa8eb
chore: no build in CI
ccssmnn Feb 2, 2026
4f107e8
chore: improve implementation
ccssmnn Feb 4, 2026
2c9d2eb
fix: delete old settings only after new ones are persisted
ccssmnn Feb 4, 2026
27573b6
fix: clear cached worker on rejection to allow retries
ccssmnn Feb 4, 2026
aaa0018
fix: Add device only after successful server registration
ccssmnn Feb 4, 2026
69fa7af
fix: more improvements
ccssmnn Feb 4, 2026
8d60d95
fix: type error in notification-registration
ccssmnn Feb 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
check:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
Comment on lines +16 to +18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find any repo-defined Bun version sources
echo "=== Searching for version files ==="
fd -a --hidden -E node_modules -E .git '(\.bun-version|\.tool-versions|package\.json)$' 2>/dev/null | head -20

echo ""
echo "=== Checking for bun version hints ==="
fd -a --hidden -E node_modules -E .git '(\.bun-version|\.tool-versions)$' -exec cat {} \; 2>/dev/null

echo ""
echo "=== Checking package.json for engines.bun ==="
if [ -f package.json ]; then
  rg -A 2 '"engines"' package.json 2>/dev/null || echo "No engines field found"
fi

Repository: ccssmnn/tilly

Length of output: 234


🏁 Script executed:

#!/bin/bash
# Examine the CI workflow file to see both occurrences
echo "=== CI Workflow file content (lines 1-50) ==="
if [ -f .github/workflows/ci.yml ]; then
  cat -n .github/workflows/ci.yml | head -50
fi

Repository: ccssmnn/tilly

Length of output: 1186


🏁 Script executed:

#!/bin/bash
echo "=== Searching for bun.lock file ==="
find . -name "bun.lockb" -o -name "bun.lock" 2>/dev/null

echo ""
echo "=== Checking README for Bun version hints ==="
if [ -f README.md ]; then
  rg -i 'bun' README.md || echo "No bun references in README"
fi

Repository: ccssmnn/tilly

Length of output: 646


Pin Bun to a specific version instead of using latest to keep CI reproducible.

Using latest can break builds when Bun releases new versions. Create a .bun-version file with your target version and update the workflow to use it (or pin directly), then update both occurrences at lines 16-18 and 36-38.

🤖 Prompt for AI Agents
In @.github/workflows/ci.yml around lines 16 - 18, Replace the non-reproducible
"bun-version: latest" usage of the oven-sh/setup-bun@v2 action by pinning to a
concrete Bun version: add a .bun-version file at repo root containing the target
version (e.g. "1.8.0") or choose a specific version string, then update both
occurrences of the workflow input bun-version (currently "latest") to that exact
version string or read it from .bun-version so CI is deterministic; ensure both
places where oven-sh/setup-bun@v2 is used are changed.


- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run typecheck, lint, and format checks
run: bun run check

- name: Run tests
run: bun run test:run
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "bun test:run && astro check && astro build",
"build:node": "ASTRO_ADAPTER=node astro check && ASTRO_ADAPTER=node astro build",
"build": "astro build",
"build:prod": "astro build",
"build:node": "ASTRO_ADAPTER=node astro build",
"preview": "astro preview --port 4322",
"preview:node": "ASTRO_ADAPTER=node dotenv -e .env -- astro preview --port 4322",
"check": "concurrently -n Astro,Prettier,ESLint \"astro check\" \"prettier --check .\" \"eslint . --ext .ts,.tsx,.js,.jsx,.astro\"",
Expand Down
25 changes: 22 additions & 3 deletions src/app/features/notification-settings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { de as dfnsDe } from "date-fns/locale"
import { formatDistanceToNow } from "date-fns"
import { useIsAuthenticated } from "jazz-tools/react"
import { co } from "jazz-tools"
import { co, generateAuthToken } from "jazz-tools"
import { PushDevice, UserAccount } from "#shared/schema/user"
import { Alert, AlertTitle, AlertDescription } from "#shared/ui/alert"
import { ExclamationTriangle } from "react-bootstrap-icons"
Expand Down Expand Up @@ -55,6 +55,7 @@ import { PUBLIC_VAPID_KEY } from "astro:env/client"
import { getServiceWorkerRegistration } from "#app/lib/service-worker"
import { tryCatch } from "#shared/lib/trycatch"
import { isInAppBrowser } from "#app/hooks/use-pwa"
import { triggerNotificationRegistration } from "#app/lib/notification-registration"

export function NotificationSettings({
me,
Expand Down Expand Up @@ -967,11 +968,29 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) {
return
}

addPushDevice({
let deviceData = {
deviceName: values.deviceName,
endpoint: subscriptionResult.data.endpoint,
keys: subscriptionResult.data.keys,
})
}

if (notifications?.$jazz.id) {
let authToken = generateAuthToken(me)
let registrationResult = await triggerNotificationRegistration(
notifications.$jazz.id,
authToken,
)
if (!registrationResult.ok) {
toast.warning(t("notifications.toast.registrationFailed"))
setOpen(false)
form.reset({
deviceName: getDeviceName(),
})
return
}
}

addPushDevice(deviceData)

toast.success(t("notifications.toast.deviceAdded"))
setOpen(false)
Expand Down
62 changes: 62 additions & 0 deletions src/app/hooks/use-register-notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, test, expect } from "vitest"
import { findLatestFutureDate } from "#app/lib/reminder-utils"

describe("findLatestFutureDate", () => {
let today = "2025-01-15"

test("returns undefined for empty list", () => {
expect(findLatestFutureDate([], today)).toBeUndefined()
})

test("returns undefined when all reminders are in the past", () => {
let reminders = [
{ dueAtDate: "2025-01-10", deleted: false, done: false },
{ dueAtDate: "2025-01-14", deleted: false, done: false },
]
expect(findLatestFutureDate(reminders, today)).toBeUndefined()
})

test("returns the only future reminder", () => {
let reminders = [{ dueAtDate: "2025-01-20", deleted: false, done: false }]
expect(findLatestFutureDate(reminders, today)).toBe("2025-01-20")
})

test("returns today's date as valid future", () => {
let reminders = [{ dueAtDate: "2025-01-15", deleted: false, done: false }]
expect(findLatestFutureDate(reminders, today)).toBe("2025-01-15")
})

test("returns the latest of multiple future reminders", () => {
let reminders = [
{ dueAtDate: "2025-01-20", deleted: false, done: false },
{ dueAtDate: "2025-02-15", deleted: false, done: false },
{ dueAtDate: "2025-01-25", deleted: false, done: false },
]
expect(findLatestFutureDate(reminders, today)).toBe("2025-02-15")
})

test("ignores deleted reminders", () => {
let reminders = [
{ dueAtDate: "2025-02-15", deleted: true, done: false },
{ dueAtDate: "2025-01-20", deleted: false, done: false },
]
expect(findLatestFutureDate(reminders, today)).toBe("2025-01-20")
})

test("ignores done reminders", () => {
let reminders = [
{ dueAtDate: "2025-02-15", deleted: false, done: true },
{ dueAtDate: "2025-01-20", deleted: false, done: false },
]
expect(findLatestFutureDate(reminders, today)).toBe("2025-01-20")
})

test("returns undefined when all future reminders are deleted or done", () => {
let reminders = [
{ dueAtDate: "2025-02-15", deleted: true, done: false },
{ dueAtDate: "2025-01-20", deleted: false, done: true },
{ dueAtDate: "2025-01-10", deleted: false, done: false },
]
expect(findLatestFutureDate(reminders, today)).toBeUndefined()
})
})
161 changes: 161 additions & 0 deletions src/app/hooks/use-register-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { useEffect, useRef } from "react"
import { useAccount } from "jazz-tools/react"
import {
Group,
generateAuthToken,
type co,
type ResolveQuery,
} from "jazz-tools"
import { PUBLIC_JAZZ_WORKER_ACCOUNT } from "astro:env/client"
import { UserAccount } from "#shared/schema/user"
import { tryCatch } from "#shared/lib/trycatch"
import {
migrateNotificationSettings,
addServerToGroup,
} from "#app/lib/notification-settings-migration"
import { triggerNotificationRegistration } from "#app/lib/notification-registration"
import { findLatestFutureDate } from "#app/lib/reminder-utils"

export { useRegisterNotifications }

let notificationSettingsQuery = {
root: {
notificationSettings: true,
people: { $each: { reminders: { $each: true } } },
},
} as const satisfies ResolveQuery<typeof UserAccount>

type LoadedAccount = co.loaded<
typeof UserAccount,
typeof notificationSettingsQuery
>

/**
* Hook that registers notification settings with the server.
* Handles migration from account-owned to group-owned settings.
* Runs once on app start.
*/
function useRegisterNotifications(): void {
let registrationRan = useRef(false)
let me = useAccount(UserAccount, { resolve: notificationSettingsQuery })

useEffect(() => {
if (registrationRan.current || !me.$isLoaded) return
if (!me.root.notificationSettings) return

registrationRan.current = true
registerNotificationSettings(me).catch(error => {
console.error("[Notifications] Registration error:", error)
registrationRan.current = false
})
}, [me.$isLoaded, me])
}

async function registerNotificationSettings(me: LoadedAccount): Promise<void> {
let notificationSettings = me.root.notificationSettings
if (!notificationSettings) return

let serverAccountId = PUBLIC_JAZZ_WORKER_ACCOUNT
if (!serverAccountId) {
console.error("[Notifications] PUBLIC_JAZZ_WORKER_ACCOUNT not configured")
return
}

// Sync language from root to notification settings
let rootLanguage = me.root.language
if (rootLanguage && notificationSettings.language !== rootLanguage) {
notificationSettings.$jazz.set("language", rootLanguage)
}

// Compute and sync latestReminderDueDate
let latestDueDate = computeLatestReminderDueDate(me)
if (latestDueDate !== notificationSettings.latestReminderDueDate) {
notificationSettings.$jazz.set("latestReminderDueDate", latestDueDate)
}

// Check if settings are owned by a shareable group
// The key difference: if owner is an Account vs a Group
let owner = notificationSettings.$jazz.owner
let isShareableGroup = owner instanceof Group

if (!isShareableGroup) {
console.log("[Notifications] Migrating to shareable group")
let migrationResult = await tryCatch(
migrateNotificationSettings(notificationSettings, serverAccountId, {
loadAs: me,
rootLanguage,
}),
)
if (!migrationResult.ok) {
console.error("[Notifications] Migration failed:", migrationResult.error)
return
}
let { newSettings, cleanup } = migrationResult.data
// Update root to point to new settings before cleanup
me.root.$jazz.set("notificationSettings", newSettings)
// Defer cleanup to next tick so new settings are persisted first
setTimeout(cleanup, 0)
notificationSettings = newSettings
console.log("[Notifications] Migration complete")
} else {
// Ensure server worker is a member
let group = owner as Group
let serverIsMember = group.members.some(
m => m.account?.$jazz.id === serverAccountId,
)
if (!serverIsMember) {
let addResult = await tryCatch(
addServerToGroup(group, serverAccountId, { loadAs: me }),
)
if (!addResult.ok) {
console.error(
"[Notifications] Failed to add server to group:",
addResult.error,
)
}
}
}

// Register with server using Jazz auth
let authToken = generateAuthToken(me)
let registerResult = await triggerNotificationRegistration(
notificationSettings.$jazz.id,
authToken,
)

if (!registerResult.ok) {
console.error("[Notifications] Registration failed:", registerResult.error)
return
}

console.log("[Notifications] Registration successful")
}

function computeLatestReminderDueDate(me: LoadedAccount): string | undefined {
let reminders = extractReminders(me)
let timezone =
me.root.notificationSettings?.timezone ||
Intl.DateTimeFormat().resolvedOptions().timeZone
let today = new Date()
.toLocaleDateString("sv-SE", { timeZone: timezone })
.slice(0, 10)
return findLatestFutureDate(reminders, today)
}

function extractReminders(
me: LoadedAccount,
): { dueAtDate: string; deleted: boolean; done: boolean }[] {
let reminders: { dueAtDate: string; deleted: boolean; done: boolean }[] = []
for (let person of me.root.people.values()) {
if (!person || person.deletedAt) continue
for (let reminder of person.reminders.values()) {
if (!reminder) continue
reminders.push({
dueAtDate: reminder.dueAtDate,
deleted: !!reminder.deletedAt,
done: !!reminder.done,
})
}
}
return reminders
}
43 changes: 43 additions & 0 deletions src/app/lib/notification-registration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { apiClient } from "#app/lib/api-client"
import { tryCatch } from "#shared/lib/trycatch"

export { triggerNotificationRegistration }

type RegistrationResult = { ok: true } | { ok: false; error: string }

async function triggerNotificationRegistration(
notificationSettingsId: string,
authToken: string,
): Promise<RegistrationResult> {
let result = await tryCatch(
apiClient.push.register.$post(
{
json: { notificationSettingsId },
},
{
headers: {
Authorization: `Jazz ${authToken}`,
},
},
),
)

if (!result.ok) {
console.error("[Notifications] Registration failed:", result.error)
return { ok: false, error: "Network error" }
}

if (!result.data.ok) {
let errorData = await tryCatch(
result.data.json() as Promise<{ message?: string }>,
)
let errorMessage = errorData.ok
? errorData.data.message || "Unknown error"
: "Unknown error"
console.error("[Notifications] Registration error:", errorMessage)
return { ok: false, error: errorMessage }
}

console.log("[Notifications] Registration triggered successfully")
return { ok: true }
}
Loading