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

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

build:
runs-on: ubuntu-latest
needs: check

steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

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

- name: Build
run: bun run build:prod
107 changes: 107 additions & 0 deletions ATP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# ATP: Push Notification Migration

## Prerequisites

- Device with existing push notifications enabled
- Device/browser for new user signup
- Server deployed with new code
- Ability to trigger cron job (`/push/deliver-notifications`)
- Jazz dev tools access
- `CRON_SECRET` from `.env`

## Trigger Cron Job

```bash
curl -X GET http://localhost:4321/api/push/deliver-notifications \
-H "Authorization: Bearer $CRON_SECRET"
```

For local testing:

```bash
curl -X GET http://localhost:4321/api/push/deliver-notifications \
-H "Authorization: Bearer $CRON_SECRET"
```

---

## ATP 1: Existing User Migration

**Goal:** Verify existing users migrate to group-owned settings and continue receiving notifications

### Pre-flight

1. Open Jazz dev tools as existing user with push enabled
2. Note `notificationSettings` owner (should be Account, not Group)

### Steps

| Step | Action | Expected |
| ---- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| 1 | Open app | App loads |
| 2 | Check browser console | `[Notifications] Migrating to shareable group` → `Migration complete` → `Registration successful` |
| 3 | Check Jazz dev tools | `notificationSettings` owner is now Group with server as member |
| 4 | Check `root.notificationSettingsRefs` on server account | Entry exists with your `userId`, recent `lastSyncedAt`, link to your settings |
| 5 | Verify notification settings UI | All previously registered devices still visible |
| 6 | Trigger cron job | User appears in results, `success: true` |
| 7 | Receive push notification | Notification arrives on device |

### Failure indicators

- Console shows `Migration failed` or `Registration failed`
- Devices disappeared from settings
- Server refs list empty or missing user
- Cron returns no results or `success: false`

---

## ATP 2: New User Push Setup

**Goal:** Verify new users can enable push and receive notifications

| Step | Action | Expected |
| ---- | ----------------------------------------- | ------------------------------------------------ |
| 1 | Sign up as new user | Account created |
| 2 | Go to Settings → Notifications | Page loads |
| 3 | Enable push notifications, add device | Device added, success toast |
| 4 | Check console | `Registration successful` (no migration message) |
| 5 | Check server's `notificationSettingsRefs` | New user entry exists |
| 6 | Trigger cron job | New user in results |
| 7 | Receive push notification | Notification arrives |

---

## ATP 3: Re-registration on App Open

**Goal:** Verify `latestReminderDueDate` updates on each app open

| Step | Action | Expected |
| ---- | --------------------------------------------------------------------- | -------------------------------------------------------------- |
| 1 | As existing user, create reminder for future date (e.g., 2 weeks out) | Reminder created |
| 2 | Close and reopen app | App loads |
| 3 | Check console | `Registration successful` |
| 4 | Check `notificationSettings` in Jazz dev tools | `latestReminderDueDate` matches furthest reminder (YYYY-MM-DD) |
| 5 | Check server ref | `lastSyncedAt` updated to recent time |

---

## ATP 4: Stale Ref Cleanup

**Goal:** Verify inactive users are cleaned up from server refs

| Step | Action | Expected |
| ---- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| 1 | Register a test user | User in server refs |
| 2 | In Jazz dev tools, manually set `lastSyncedAt` to 31+ days ago | - |
| 3 | Ensure user has no future reminders (or set `latestReminderDueDate` to past date) | - |
| 4 | Trigger cron | Console shows `Marking stale ref for removal`, user removed from refs |
| 5 | Open app as that user | Re-registers, back in refs |

---

## Verification Checklist

- [ ] ATP 1: Existing user migration
- [ ] ATP 2: New user setup
- [ ] ATP 3: Re-registration updates
- [ ] ATP 4: Stale cleanup
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
16 changes: 16 additions & 0 deletions src/app/features/notification-settings.tsx
Original file line number Diff line number Diff line change
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 @@ -973,6 +974,21 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) {
keys: subscriptionResult.data.keys,
})

// Trigger registration with server after adding device
if (notifications?.$jazz.id) {
let registrationResult = await triggerNotificationRegistration(
notifications.$jazz.id,
)
if (!registrationResult.ok) {
toast.warning(t("notifications.toast.registrationFailed"))
setOpen(false)
form.reset({
deviceName: getDeviceName(),
})
return
}
}

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()
})
})
Loading
Loading