shibammandal/Calentic

0

stars

7

commits

JavaScript

primary language

Aug 27, 2026

updated

README

Calentic

Calentic

Your conversations, organised. Never miss a plan again.

Expo SDK 57 · Android · Team QuantumnCodes · Sister Nivedita University


Plans get made in messaging apps. Calendars only read email invites. Calentic closes that gap by reading the notification, not the app — so it works with WhatsApp, Telegram, Discord and Instagram without integrating with any of them.

Quick start

npm install
npm start          # Expo Go: full UI + pipeline, demo feed only
npm test           # dates, calendar, editing, follow-ups, database — offline

Scan the QR with Expo Go and the demo feed turns itself on, so the whole pipeline is visible straight away.

Live notification capture does not work in Expo Go. Reading other apps' notifications needs NotificationListenerService — custom native Kotlin, which Expo Go cannot load. For real capture, build a dev client:

npx eas-cli@latest login
npm run eas:dev            # installable APK, built in Expo's cloud
npm run start:dev-client   # then install the APK and connect

JS changes hot-reload; native changes need a new build. The projectId is already in app.json.


The UI

Four tabs on a bottom bar, plus a badged bell holding everything that needs you.

┌──────────────────────────────────────┐   ┌──────────────────────────────────────┐
│  Calentic            ● live   🔔 3   │   │  ←  Needs you                    3   │
│                                      │   │                                      │
│  [ All ] [ High ] [ Med ] [ Low ]    │   │  ┌────────────────────────────────┐  │
│                                      │   │  │ Coffee with Rohit              │  │
│  OVERDUE                             │   │  │ Tomorrow 7:00 PM               │  │
│  ┃ Submission deadline      High     │   │  │ WhatsApp · Rohit               │  │
│  ┃ Yesterday 11:59 PM                │   │  │ "kal sham 7 baje coffee?"      │  │
│                                      │   │  │      [ Dismiss ]   [ Accept ]  │  │
│  TODAY                               │   │  └────────────────────────────────┘  │
│  9:00 AM · 3 at the same time        │   │  ┌────────────────────────────────┐  │
│  ┃ Submission deadline      High     │   │  │ Undateable plan            !   │  │
│  ┃ DBMS lab                 Medium   │   │  │ No date in "agle hafte kabhi"  │  │
│  ┃ Coffee with Rohit        Low      │   │  │   [ Not a plan ]  [ Add date ] │  │
│                                      │   │  └────────────────────────────────┘  │
│  ┃ Viva prep                Medium   │   │  ┌────────────────────────────────┐  │
│  ┃ 4:30 PM   ⚑ change requested      │   │  │ CHANGES REQUESTED              │  │
│                                      │   │  │ Cancel "DBMS lab" · Today 9AM  │  │
│  UPCOMING                            │   │  │ "its cancelled"                │  │
│  ┃ Coffee with Rohit        Low      │   │  │   [ Keep it ]    [ Cancel it ] │  │
│  ┃ Tomorrow 7:00 PM                  │   │  └────────────────────────────────┘  │
│                                      │   │                                      │
│  ▸ DONE (4)                          │   │           [ Update all ]             │
│                                      │   │                                      │
│  ●Tasks  ○Assistant  ○Feed  ○Setup   │   │                                      │
└──────────────────────────────────────┘   └──────────────────────────────────────┘
       Tasks — priority first                  The bell — nothing auto-applies

┌──────────────────────────────────────┐   ┌──────────────────────────────────────┐
│  Assistant                           │   │  Edit task                       ✕   │
│                                      │   │                                      │
│          Show me my calendar   ◄     │   │  Title    Coffee with Rohit          │
│          for this month              │   │                                      │
│                                      │   │  When     kal sham 7 baje            │
│  ┌────────────────────────────────┐  │   │           → Tomorrow, 7:00 PM        │
│  │  September             ‹   ›   │  │   │                                      │
│  │  M   T   W   T   F   S   S    │  │   │  Priority  [High] [Med]  ●Low        │
│  │  1   2   3   4   5   6   7    │  │   │  Kind      [Meet] ●Coffee [Exam]     │
│  │          ▪▪      ▪            │  │   │  Place     Cafe Coffee Day           │
│  │  8   9  10  11  12  13  14    │  │   │  All-day   [ off ]                   │
│  │              ▪                │  │   │                                      │
│  │ 15  16  17  18  19  20  21    │  │   │  [ Cancel ]             [ Accept ]   │
│  └────────────────────────────────┘  │   │                                      │
│                                      │   └──────────────────────────────────────┘
│  ▸ 3 days have something this month  │         Every decision happens here
│                                      │
│  [ Move the coffee to Friday 3pm  ▸] │
└──────────────────────────────────────┘
    Assistant — tool calls, and it draws

The pipeline

01 LISTEN            02 UNDERSTAND                      03 CONFIRM
---------            -------------                      ----------
notification     ->  local regex filter (free)      ->  stored unconfirmed
captured             | survivors only                   |
                     v                                  v
                     model — schema-constrained JSON     card now, or the bell
                     |                                  |
                     v                                  v
                     chrono-node -> absolute time        Accept -> task + T-30
                       no date? -> kept as incomplete
                       a change to a plan? -> a proposal

The two-stage split is the point: a regex gate rejects most notifications for free, so the model is only ever asked about plausible plans. On the built-in demo script that gate rejects 56% before any network call — measured, not claimed.

"Bhai kal sham 7 baje coffee?"
  -> filter:  time + plan intent -> pass
  -> extract: {type: coffee, datetime_text: "kal sham 7 baje", confidence: 0.88}
  -> resolve: Hinglish normalised -> tomorrow, 7:00 PM
  -> card:    "Coffee with Rohit · Tomorrow 7:00 PM"  [Dismiss] [Accept]
  -> reminder fires 6:30 PM tomorrow

Reading the time, in any language

chrono-node parses no spelled-out hour in any language — not "das baje", and not "ten o'clock" either. So the hour becomes a digit before the parser sees it:

das baje        -> 10 o'clock     saade das baje -> 10:30     sava char -> 4:15
paune das baje  -> 9:45           dedh / dhai    -> 1:30 / 2:30
pathu mani (ta) · padi gantalu (te) · hattu gante (kn) · daha vajta (mr)
zehn uhr (de)   · dix heures (fr)  · diez en punto (es)      -> 10 o'clock

Covered for Hindi/Urdu, Marathi, Bengali, Punjabi, Gujarati, Tamil, Telugu, Kannada, Malayalam, English, Spanish, French and German, in Latin transliteration with several spellings each — there is no one correct way to romanise Hindi, so "gyarah", "gyara" and "igyara" are all things a real person writes for 11.

A number word only counts next to a clock marker. Half that table is an ordinary word in another language: Hindi do is English "do", Hindi das is German "das", German elf is an English noun. So das baje becomes 10, while do it tomorrow, das ist gut and the elf costume are left completely alone. Every marker (baje, vaje, mani, gante, uhr, heures, en punto) folds into "o'clock" first, so the number pass has one shape to recognise instead of a dozen. npm run test:dates asserts exactly that.


Nothing enters your calendar without you

A resolved candidate is written to SQLite immediately as status = 'unconfirmed' — a real row from the instant it is detected, but not yet a task. It touches nothing (no list, no counts, no calendar, no reminder) until you accept. Stage 03's card is a moment; the bell is the durable copy of it.

OutcomeWhat happens
Acceptpromoted in place to confirmed — same row, same id. Joins the list, writes to the calendar, arms the reminder.
Dismissthe row is deleted. The only action that throws a plan away.
Ignorethe card times out, the app closes — it stays unconfirmed and waits in the bell.

Letting a card time out is no longer a decision. Accept is the only path to a task, Dismiss the only path to the bin. All three entrances (sheet, notification button, bell) call one function keyed by row id — the notification carries only that id, because Android can kill the process at any moment and an id still resolves where an in-memory candidate would not.

Nothing is accepted from the list itself. Every button opens the plan in the edit sheet first: the model's reading is a guess, and the moment before something enters your calendar is when you want to fix a wrong title. Saving an untouched form still counts as Accept. The one shortcut is Update all, for a batch that clearly needs no review.

Plans that cannot be dated are kept, as status = 'incomplete', with the phrase that defeated us quoted back. These get no Accept button — a task at an invented time is worse than one the user had to finish themselves. "Add a date" opens the sheet with the when field focused, so typing the missing piece is the decision. Such a row satisfies start_iso NOT NULL by holding its arrival time, which is never shown and never used: it is excluded from the list, the counts, the calendar and reminders, and ages out by arrival.

One list, newest first, whatever kind each item is — they all mean "this needs you", and a heading per kind made a short list look like an org chart. What tells them apart is on the card: nothing + Accept, a yellow ! + Update, or a rose border + Cancel it / Move it. Candidates more than three days past their start are pruned at launch and the bell caps at 50, so an inbox nobody empties cannot grow without bound. Confirmed tasks are never touched.


When somebody changes a plan

Rohit, 08:04   "hey theres a lab today, its at around 9 be ready"
Rohit, 08:20   "its cancelled"

The second message cannot be ignored — your schedule goes stale. It also cannot be applied, because a sender who can silently edit your calendar is a sender who controls your schedule. Notification text is untrusted input. So a follow-up never changes anything; it creates a proposal that waits in the bell.

Only whoever set a task up can propose changing it. Rohit told you about the lab, so only Rohit's messages can touch it — someone else saying "lab cancelled" links to nothing. One rule, most of the abuse surface gone, explainable to a user in a sentence. It is enforced twice: the shortlist offered to the model contains only that sender's tasks, and the link is re-checked against the sender at the point of use. scripts/test-followup.mjs proves it, including the case where a stranger is handed the shortlist anyway.

Accepting a cancellation does not deletethe row becomes cancelled: out of the list, still recoverable. If the sender was wrong, you need it back.
A disputed task looks disputeda "change requested" marker on the row, so you do not walk into the cancelled lab with the question unanswered.
Urgent proposals escalateagainst something starting within three hours it also goes to the notification shade, with Keep it / Apply.
A sender repeating themselves is not newsone open proposal per task per kind.
Withdrawn before you answeredif the plan was still unconfirmed there is nothing to propose against — the candidate is dropped.
A change that cannot be linked is not a new planreading "its cancelled" as a fresh event would invent one. It is recorded as unlinked and discarded.

What it costs: linking means the extraction call now carries a shortlist of that sender's open tasks — id, title, time. That is a real change to the privacy story (it used to be the notification text alone), defensible only because the sender already told you those things — and exactly why the shortlist may never contain anybody else's tasks.

npm run test:scenario runs the story above through the deployed Llama, via /chat rather than /extract, so it answers "can the model do this at all" independently of what is currently deployed.


The assistant, and the calendar it draws

A tool-calling loop over the same SQLite file. Six tools: list_tasks, show_calendar, create_task, update_task, complete_task, delete_task. The model never touches the database — it asks for a tool, the app runs it and hands back the result, so every write is auditable and an id it did not see in a list_tasks result cannot be acted on.

That guard is not theoretical. Asked to "make the DBMS demo high priority", the 8B's first move was update_task(id=12345) — an id it made up. The app rejected it, and the error text is what let the model call list_tasks and retry with the real id. Handing failures back to the model rather than throwing is why that recovers instead of corrupting something.

"Show me my calendar for this month"  -> show_calendar(view="month")
"What does next week look like?"      -> show_calendar(view="week", date="next week")

A month renders as a grid — the point is seeing where the clear weeks and the pile-ups are — with a priority-coloured marker per task and a tap-through to any day. A week or a day renders as an agenda instead: seven columns of one dot each tells you less than seven lines of "when, what".

The date argument is a phrase in the user's own words, resolved by the same src/shared/resolve.js the pipeline uses, so "next week", "28 tarikh" and "kal" mean in chat what they mean in a captured message. Two plumbing notes: the tool returns two different things — a summary for the model, every event for the UI, so a month need not fit through a small model's context window; and the model is asked for one JSON object per turn against the self-hosted Llama, because free-form tool syntax is unreliable at 8B. Gemini uses native function calling. Both read the same catalogue from src/shared/tools.js, so neither can drift from what the app implements.


Editing a task

Tap any task to edit all of it — title, when, priority, kind, place, all-day — or just say "Move the DBMS project demo to Friday 3pm". Both go through the same merge in src/shared/edit.js, so a chat edit and a hand edit cannot disagree about what "move it to 7pm" means.

The "when" field takes a phrase, not a date picker. Type kal sham 7 baje or 28 tarikh and the sheet shows what it resolved to, live, as you type. A picker means a native dependency that breaks Expo Go, typing beats spinning three wheels, and this app exists to understand exactly that kind of phrase. The preview line is what makes it safe: you never save a phrase without seeing the date it became. Blank leaves the time alone.

An edit is never just a row update, because a task lives in three places:

ChangeWhat follows
the timethe end shifts by the same amount, the calendar entry moves, the reminder is re-armed, and a task that already fired can fire again
the durationthe end moves, the start does not
to all-daythe clock is dropped, the date is kept, the end time goes
the title or placethe calendar entry is updated; the reminder is left alone
into the pastthe reminder is dropped rather than firing instantly

All of that lives in src/tasks.js, the single write layer. Before it existed the confirm path did the calendar and the reminder correctly and the assistant did neither — so a task created by chat never reminded anybody. Every write now goes through one place.


The task list

BucketWhat it holds
Overduestart time passed, never ticked off
Todayhappening today, still open
Upcominglater than today, still open
Doneticked off, newest first (collapsed by default)

Within each bucket, rows sort by priority first, time second — a high-priority deadline later today outranks a low-priority coffee in an hour. The filter row narrows to one priority without collapsing the buckets.

Priority is about consequence, not proximity. Deadlines, exams, vivas and flights are high; ordinary meetings and classes medium; loose or hedged plans low. The model is asked for it directly (a required field in the extraction schema), and guessPriority() in src/shared/lang.js powers the offline path and backfills anything a model leaves blank, so nothing lands in an unsorted limbo. It shows as a coloured rail plus a word, never colour alone, since it is the field the whole list is sorted by.

Tasks starting at the same moment stack into one block, because a clash is what you most want to notice and two rows at 9:00 with an 8:00 row between them do not read as one. stackByTime() regroups by time, then re-orders the groups by the best priority inside each — keeping "most consequential first" while putting a collision where you can see it. All-day tasks group by date; a clock time they do not have cannot clash.


Structure

calentic/
|-- app.json / eas.json         package, permissions, plugins, build profiles
|-- index.js / App.jsx          four tabs on a bottom bar + the bell inbox
|-- src/
|   |-- shared/                 framework-free: no React Native imports at all
|   |   |-- filter-rules.js       stage 02a: the noise filter
|   |   |-- lang.js               vocabulary, number words, bare day-of-month
|   |   |-- prompt.js             stage 02b: system prompt, few-shots, JSON schema
|   |   |-- resolve.js            stage 02c: chrono-node + casual-chat date fixes
|   |   |-- agenda.js             calendar ranges, month grids, clash stacking
|   |   |-- followup.js           who may change a plan, and whether to believe it
|   |   |-- edit.js / schema.js   merging an edit; the SQL, tables and migrations
|   |   `-- tools.js              the assistant's tool catalogue
|   |-- pipeline.js             orchestrates 01 -> 02 -> 03
|   |-- extractor.js            proxy / Gemini / Claude / offline
|   |-- assistant.js            the chat tool-calling loop over the task database
|   |-- tasks.js                the write layer: SQLite + calendar + reminders
|   |-- listener.js             native module bridge + scripted demo feed
|   |-- background.js           foreground service + actionable notifications
|   |-- db.js / reminders.js / calendar.js / config.js / keyboard.js / theme.js
|   `-- components/             TaskList, Inbox, Attention, ConfirmSheet,
|                               EditSheet, Chat, CalendarCard, BottomNav, Glass
|-- scripts/                    test-dates, -agenda, -edit, -followup, -scenario,
|                               -db, -pipeline, -assistant, gen-llama-notebook
|-- notebooks/                  calentic-llama-kaggle.ipynb — GENERATED
`-- modules/calentic-listener/  LOCAL EXPO MODULE — autolinked
    `-- android/.../            CalenticListenerService.kt    the actual listener
                                CalenticForegroundService.kt  keeps it alive
                                CalenticListenerModule.kt     Expo bridge

src/shared/ imports nothing from React Native. Plain JS with chrono-node as its only dependency, so it unit-tests in plain Node and another client could pick it up later. That is why the SQL lives in shared/schema.js rather than in db.js: db.js needs expo-sqlite, but the schema and its migrations can then run against real SQLite from a test (npm test) — the part where a mistake costs somebody their data.

The module declares its own service. The Expo module ships an AndroidManifest.xml, so the <service> entry is merged into the app manifest automatically: nothing in the app needs editing, and prebuild --clean cannot lose it.


Configuration

Copy .env.example to .env:

EXPO_PUBLIC_CALENTIC_PROXY_URL=
EXPO_PUBLIC_GEMINI_API_KEY=
EXPO_PUBLIC_ANTHROPIC_API_KEY=

Precedence: proxy > Gemini > Anthropic > offline fallback. The default model is gemini-3.6-flash, set in app.json under expo.extra with the other non-secret knobs (reminder lead, confidence floor). With no key the app runs the offline rule-based extractor and the Setup tab says so.

Anything prefixed EXPO_PUBLIC_ is inlined into the JS bundle and is extractable from the APK. Fine for a demo on your own device; not fine for a release — that is what the proxy variable is for, and it wins when both are set.

Notification access: Setup → Grant access. Android has no permission dialog for it, so this opens Settings → Notifications → Device & app notifications. The header dot turns green once it is granted and the service has bound.

Android Studio: npm run prebuild writes android/ from app.json + modules/; then open android — the Gradle root, not the repo root. android/ is generated and gitignored, so do not hand-edit it; native config belongs in app.json or modules/calentic-listener/. A local build needs JDK 17 plus the Android SDK; EAS Build needs neither.


Status and known gaps

Filter + Hinglish date resolververified in plain Node: 10/10 date cases, 8/8 plans caught, 0 false positives
Pipeline, UI, confirm sheet, Expo module, Kotlin listenerwritten
JS bundlesverified — 1.7 MB, exit 0, pipeline confirmed present
Kotlin compiled · running on a device · live Claude callNOT DONE
  • Nothing native has been compiled. Expect Kotlin and Gradle fixes on the first eas:dev or run:android.
  • No live Claude call has ever been made. The cost-per-notification figure is computed from list pricing, not measured. Set a key and watch the Live tab.
  • Background pipeline. The listener buffers up to 200 notifications while no JS listener is attached, then drops the oldest. Notifications arriving while the app is fully stopped are processed on next launch, not in the background — real background handling needs a foreground service or a headless task.
  • The confirm sheet only appears in the foreground. For a real product it should be a full-screen intent or an actionable notification.
  • USE_EXACT_ALARM is declared for reminders. Google Play restricts it to apps whose core function is alarms/reminders; if policy pushes back, drop to SCHEDULE_EXACT_ALARM.

Privacy: what actually leaves the device

The regex filter is a cost control, not a privacy mechanism. Being precise about this matters for what the pitch can claim.

  • Notifications the filter rejects never leave the device.
  • Notifications that pass go to the model in full — app, sender name, and complete message body. On the demo script that is 8 of 18 (44%).
  • The filter forwards precisely the messages carrying the most sensitive detail (who, when, where) and discards the harmless chatter.
  • Every notification body, including rejected ones, is written to plaintext SQLite with no retention limit.

Storage is local-first, but the cloud call is not opt-in — it is the core of the pipeline, so any "local-first, cloud sync is opt-in" claim needs rewording. Fixes worth making: stop storing rejected bodies (log a verdict and hash instead), prune notifications after N days, strip the sender name before the call and re-attach it locally, and make cloud extraction a real toggle with offline as the default.


The Electron desktop client built alongside this was working and verified, but it was never committed to git — so C:\Work\calentic-desktop-archive\ is the only copy.

Contributors

shibammandal

7 commits

shibammandal/Calentic

0

stars

7

commits

JavaScript

primary language

Aug 27, 2026

updated

README

Calentic

Calentic

Your conversations, organised. Never miss a plan again.

Expo SDK 57 · Android · Team QuantumnCodes · Sister Nivedita University


Plans get made in messaging apps. Calendars only read email invites. Calentic closes that gap by reading the notification, not the app — so it works with WhatsApp, Telegram, Discord and Instagram without integrating with any of them.

Quick start

npm install
npm start          # Expo Go: full UI + pipeline, demo feed only
npm test           # dates, calendar, editing, follow-ups, database — offline

Scan the QR with Expo Go and the demo feed turns itself on, so the whole pipeline is visible straight away.

Live notification capture does not work in Expo Go. Reading other apps' notifications needs NotificationListenerService — custom native Kotlin, which Expo Go cannot load. For real capture, build a dev client:

npx eas-cli@latest login
npm run eas:dev            # installable APK, built in Expo's cloud
npm run start:dev-client   # then install the APK and connect

JS changes hot-reload; native changes need a new build. The projectId is already in app.json.


The UI

Four tabs on a bottom bar, plus a badged bell holding everything that needs you.

┌──────────────────────────────────────┐   ┌──────────────────────────────────────┐
│  Calentic            ● live   🔔 3   │   │  ←  Needs you                    3   │
│                                      │   │                                      │
│  [ All ] [ High ] [ Med ] [ Low ]    │   │  ┌────────────────────────────────┐  │
│                                      │   │  │ Coffee with Rohit              │  │
│  OVERDUE                             │   │  │ Tomorrow 7:00 PM               │  │
│  ┃ Submission deadline      High     │   │  │ WhatsApp · Rohit               │  │
│  ┃ Yesterday 11:59 PM                │   │  │ "kal sham 7 baje coffee?"      │  │
│                                      │   │  │      [ Dismiss ]   [ Accept ]  │  │
│  TODAY                               │   │  └────────────────────────────────┘  │
│  9:00 AM · 3 at the same time        │   │  ┌────────────────────────────────┐  │
│  ┃ Submission deadline      High     │   │  │ Undateable plan            !   │  │
│  ┃ DBMS lab                 Medium   │   │  │ No date in "agle hafte kabhi"  │  │
│  ┃ Coffee with Rohit        Low      │   │  │   [ Not a plan ]  [ Add date ] │  │
│                                      │   │  └────────────────────────────────┘  │
│  ┃ Viva prep                Medium   │   │  ┌────────────────────────────────┐  │
│  ┃ 4:30 PM   ⚑ change requested      │   │  │ CHANGES REQUESTED              │  │
│                                      │   │  │ Cancel "DBMS lab" · Today 9AM  │  │
│  UPCOMING                            │   │  │ "its cancelled"                │  │
│  ┃ Coffee with Rohit        Low      │   │  │   [ Keep it ]    [ Cancel it ] │  │
│  ┃ Tomorrow 7:00 PM                  │   │  └────────────────────────────────┘  │
│                                      │   │                                      │
│  ▸ DONE (4)                          │   │           [ Update all ]             │
│                                      │   │                                      │
│  ●Tasks  ○Assistant  ○Feed  ○Setup   │   │                                      │
└──────────────────────────────────────┘   └──────────────────────────────────────┘
       Tasks — priority first                  The bell — nothing auto-applies

┌──────────────────────────────────────┐   ┌──────────────────────────────────────┐
│  Assistant                           │   │  Edit task                       ✕   │
│                                      │   │                                      │
│          Show me my calendar   ◄     │   │  Title    Coffee with Rohit          │
│          for this month              │   │                                      │
│                                      │   │  When     kal sham 7 baje            │
│  ┌────────────────────────────────┐  │   │           → Tomorrow, 7:00 PM        │
│  │  September             ‹   ›   │  │   │                                      │
│  │  M   T   W   T   F   S   S    │  │   │  Priority  [High] [Med]  ●Low        │
│  │  1   2   3   4   5   6   7    │  │   │  Kind      [Meet] ●Coffee [Exam]     │
│  │          ▪▪      ▪            │  │   │  Place     Cafe Coffee Day           │
│  │  8   9  10  11  12  13  14    │  │   │  All-day   [ off ]                   │
│  │              ▪                │  │   │                                      │
│  │ 15  16  17  18  19  20  21    │  │   │  [ Cancel ]             [ Accept ]   │
│  └────────────────────────────────┘  │   │                                      │
│                                      │   └──────────────────────────────────────┘
│  ▸ 3 days have something this month  │         Every decision happens here
│                                      │
│  [ Move the coffee to Friday 3pm  ▸] │
└──────────────────────────────────────┘
    Assistant — tool calls, and it draws

The pipeline

01 LISTEN            02 UNDERSTAND                      03 CONFIRM
---------            -------------                      ----------
notification     ->  local regex filter (free)      ->  stored unconfirmed
captured             | survivors only                   |
                     v                                  v
                     model — schema-constrained JSON     card now, or the bell
                     |                                  |
                     v                                  v
                     chrono-node -> absolute time        Accept -> task + T-30
                       no date? -> kept as incomplete
                       a change to a plan? -> a proposal

The two-stage split is the point: a regex gate rejects most notifications for free, so the model is only ever asked about plausible plans. On the built-in demo script that gate rejects 56% before any network call — measured, not claimed.

"Bhai kal sham 7 baje coffee?"
  -> filter:  time + plan intent -> pass
  -> extract: {type: coffee, datetime_text: "kal sham 7 baje", confidence: 0.88}
  -> resolve: Hinglish normalised -> tomorrow, 7:00 PM
  -> card:    "Coffee with Rohit · Tomorrow 7:00 PM"  [Dismiss] [Accept]
  -> reminder fires 6:30 PM tomorrow

Reading the time, in any language

chrono-node parses no spelled-out hour in any language — not "das baje", and not "ten o'clock" either. So the hour becomes a digit before the parser sees it:

das baje        -> 10 o'clock     saade das baje -> 10:30     sava char -> 4:15
paune das baje  -> 9:45           dedh / dhai    -> 1:30 / 2:30
pathu mani (ta) · padi gantalu (te) · hattu gante (kn) · daha vajta (mr)
zehn uhr (de)   · dix heures (fr)  · diez en punto (es)      -> 10 o'clock

Covered for Hindi/Urdu, Marathi, Bengali, Punjabi, Gujarati, Tamil, Telugu, Kannada, Malayalam, English, Spanish, French and German, in Latin transliteration with several spellings each — there is no one correct way to romanise Hindi, so "gyarah", "gyara" and "igyara" are all things a real person writes for 11.

A number word only counts next to a clock marker. Half that table is an ordinary word in another language: Hindi do is English "do", Hindi das is German "das", German elf is an English noun. So das baje becomes 10, while do it tomorrow, das ist gut and the elf costume are left completely alone. Every marker (baje, vaje, mani, gante, uhr, heures, en punto) folds into "o'clock" first, so the number pass has one shape to recognise instead of a dozen. npm run test:dates asserts exactly that.


Nothing enters your calendar without you

A resolved candidate is written to SQLite immediately as status = 'unconfirmed' — a real row from the instant it is detected, but not yet a task. It touches nothing (no list, no counts, no calendar, no reminder) until you accept. Stage 03's card is a moment; the bell is the durable copy of it.

OutcomeWhat happens
Acceptpromoted in place to confirmed — same row, same id. Joins the list, writes to the calendar, arms the reminder.
Dismissthe row is deleted. The only action that throws a plan away.
Ignorethe card times out, the app closes — it stays unconfirmed and waits in the bell.

Letting a card time out is no longer a decision. Accept is the only path to a task, Dismiss the only path to the bin. All three entrances (sheet, notification button, bell) call one function keyed by row id — the notification carries only that id, because Android can kill the process at any moment and an id still resolves where an in-memory candidate would not.

Nothing is accepted from the list itself. Every button opens the plan in the edit sheet first: the model's reading is a guess, and the moment before something enters your calendar is when you want to fix a wrong title. Saving an untouched form still counts as Accept. The one shortcut is Update all, for a batch that clearly needs no review.

Plans that cannot be dated are kept, as status = 'incomplete', with the phrase that defeated us quoted back. These get no Accept button — a task at an invented time is worse than one the user had to finish themselves. "Add a date" opens the sheet with the when field focused, so typing the missing piece is the decision. Such a row satisfies start_iso NOT NULL by holding its arrival time, which is never shown and never used: it is excluded from the list, the counts, the calendar and reminders, and ages out by arrival.

One list, newest first, whatever kind each item is — they all mean "this needs you", and a heading per kind made a short list look like an org chart. What tells them apart is on the card: nothing + Accept, a yellow ! + Update, or a rose border + Cancel it / Move it. Candidates more than three days past their start are pruned at launch and the bell caps at 50, so an inbox nobody empties cannot grow without bound. Confirmed tasks are never touched.


When somebody changes a plan

Rohit, 08:04   "hey theres a lab today, its at around 9 be ready"
Rohit, 08:20   "its cancelled"

The second message cannot be ignored — your schedule goes stale. It also cannot be applied, because a sender who can silently edit your calendar is a sender who controls your schedule. Notification text is untrusted input. So a follow-up never changes anything; it creates a proposal that waits in the bell.

Only whoever set a task up can propose changing it. Rohit told you about the lab, so only Rohit's messages can touch it — someone else saying "lab cancelled" links to nothing. One rule, most of the abuse surface gone, explainable to a user in a sentence. It is enforced twice: the shortlist offered to the model contains only that sender's tasks, and the link is re-checked against the sender at the point of use. scripts/test-followup.mjs proves it, including the case where a stranger is handed the shortlist anyway.

Accepting a cancellation does not deletethe row becomes cancelled: out of the list, still recoverable. If the sender was wrong, you need it back.
A disputed task looks disputeda "change requested" marker on the row, so you do not walk into the cancelled lab with the question unanswered.
Urgent proposals escalateagainst something starting within three hours it also goes to the notification shade, with Keep it / Apply.
A sender repeating themselves is not newsone open proposal per task per kind.
Withdrawn before you answeredif the plan was still unconfirmed there is nothing to propose against — the candidate is dropped.
A change that cannot be linked is not a new planreading "its cancelled" as a fresh event would invent one. It is recorded as unlinked and discarded.

What it costs: linking means the extraction call now carries a shortlist of that sender's open tasks — id, title, time. That is a real change to the privacy story (it used to be the notification text alone), defensible only because the sender already told you those things — and exactly why the shortlist may never contain anybody else's tasks.

npm run test:scenario runs the story above through the deployed Llama, via /chat rather than /extract, so it answers "can the model do this at all" independently of what is currently deployed.


The assistant, and the calendar it draws

A tool-calling loop over the same SQLite file. Six tools: list_tasks, show_calendar, create_task, update_task, complete_task, delete_task. The model never touches the database — it asks for a tool, the app runs it and hands back the result, so every write is auditable and an id it did not see in a list_tasks result cannot be acted on.

That guard is not theoretical. Asked to "make the DBMS demo high priority", the 8B's first move was update_task(id=12345) — an id it made up. The app rejected it, and the error text is what let the model call list_tasks and retry with the real id. Handing failures back to the model rather than throwing is why that recovers instead of corrupting something.

"Show me my calendar for this month"  -> show_calendar(view="month")
"What does next week look like?"      -> show_calendar(view="week", date="next week")

A month renders as a grid — the point is seeing where the clear weeks and the pile-ups are — with a priority-coloured marker per task and a tap-through to any day. A week or a day renders as an agenda instead: seven columns of one dot each tells you less than seven lines of "when, what".

The date argument is a phrase in the user's own words, resolved by the same src/shared/resolve.js the pipeline uses, so "next week", "28 tarikh" and "kal" mean in chat what they mean in a captured message. Two plumbing notes: the tool returns two different things — a summary for the model, every event for the UI, so a month need not fit through a small model's context window; and the model is asked for one JSON object per turn against the self-hosted Llama, because free-form tool syntax is unreliable at 8B. Gemini uses native function calling. Both read the same catalogue from src/shared/tools.js, so neither can drift from what the app implements.


Editing a task

Tap any task to edit all of it — title, when, priority, kind, place, all-day — or just say "Move the DBMS project demo to Friday 3pm". Both go through the same merge in src/shared/edit.js, so a chat edit and a hand edit cannot disagree about what "move it to 7pm" means.

The "when" field takes a phrase, not a date picker. Type kal sham 7 baje or 28 tarikh and the sheet shows what it resolved to, live, as you type. A picker means a native dependency that breaks Expo Go, typing beats spinning three wheels, and this app exists to understand exactly that kind of phrase. The preview line is what makes it safe: you never save a phrase without seeing the date it became. Blank leaves the time alone.

An edit is never just a row update, because a task lives in three places:

ChangeWhat follows
the timethe end shifts by the same amount, the calendar entry moves, the reminder is re-armed, and a task that already fired can fire again
the durationthe end moves, the start does not
to all-daythe clock is dropped, the date is kept, the end time goes
the title or placethe calendar entry is updated; the reminder is left alone
into the pastthe reminder is dropped rather than firing instantly

All of that lives in src/tasks.js, the single write layer. Before it existed the confirm path did the calendar and the reminder correctly and the assistant did neither — so a task created by chat never reminded anybody. Every write now goes through one place.


The task list

BucketWhat it holds
Overduestart time passed, never ticked off
Todayhappening today, still open
Upcominglater than today, still open
Doneticked off, newest first (collapsed by default)

Within each bucket, rows sort by priority first, time second — a high-priority deadline later today outranks a low-priority coffee in an hour. The filter row narrows to one priority without collapsing the buckets.

Priority is about consequence, not proximity. Deadlines, exams, vivas and flights are high; ordinary meetings and classes medium; loose or hedged plans low. The model is asked for it directly (a required field in the extraction schema), and guessPriority() in src/shared/lang.js powers the offline path and backfills anything a model leaves blank, so nothing lands in an unsorted limbo. It shows as a coloured rail plus a word, never colour alone, since it is the field the whole list is sorted by.

Tasks starting at the same moment stack into one block, because a clash is what you most want to notice and two rows at 9:00 with an 8:00 row between them do not read as one. stackByTime() regroups by time, then re-orders the groups by the best priority inside each — keeping "most consequential first" while putting a collision where you can see it. All-day tasks group by date; a clock time they do not have cannot clash.


Structure

calentic/
|-- app.json / eas.json         package, permissions, plugins, build profiles
|-- index.js / App.jsx          four tabs on a bottom bar + the bell inbox
|-- src/
|   |-- shared/                 framework-free: no React Native imports at all
|   |   |-- filter-rules.js       stage 02a: the noise filter
|   |   |-- lang.js               vocabulary, number words, bare day-of-month
|   |   |-- prompt.js             stage 02b: system prompt, few-shots, JSON schema
|   |   |-- resolve.js            stage 02c: chrono-node + casual-chat date fixes
|   |   |-- agenda.js             calendar ranges, month grids, clash stacking
|   |   |-- followup.js           who may change a plan, and whether to believe it
|   |   |-- edit.js / schema.js   merging an edit; the SQL, tables and migrations
|   |   `-- tools.js              the assistant's tool catalogue
|   |-- pipeline.js             orchestrates 01 -> 02 -> 03
|   |-- extractor.js            proxy / Gemini / Claude / offline
|   |-- assistant.js            the chat tool-calling loop over the task database
|   |-- tasks.js                the write layer: SQLite + calendar + reminders
|   |-- listener.js             native module bridge + scripted demo feed
|   |-- background.js           foreground service + actionable notifications
|   |-- db.js / reminders.js / calendar.js / config.js / keyboard.js / theme.js
|   `-- components/             TaskList, Inbox, Attention, ConfirmSheet,
|                               EditSheet, Chat, CalendarCard, BottomNav, Glass
|-- scripts/                    test-dates, -agenda, -edit, -followup, -scenario,
|                               -db, -pipeline, -assistant, gen-llama-notebook
|-- notebooks/                  calentic-llama-kaggle.ipynb — GENERATED
`-- modules/calentic-listener/  LOCAL EXPO MODULE — autolinked
    `-- android/.../            CalenticListenerService.kt    the actual listener
                                CalenticForegroundService.kt  keeps it alive
                                CalenticListenerModule.kt     Expo bridge

src/shared/ imports nothing from React Native. Plain JS with chrono-node as its only dependency, so it unit-tests in plain Node and another client could pick it up later. That is why the SQL lives in shared/schema.js rather than in db.js: db.js needs expo-sqlite, but the schema and its migrations can then run against real SQLite from a test (npm test) — the part where a mistake costs somebody their data.

The module declares its own service. The Expo module ships an AndroidManifest.xml, so the <service> entry is merged into the app manifest automatically: nothing in the app needs editing, and prebuild --clean cannot lose it.


Configuration

Copy .env.example to .env:

EXPO_PUBLIC_CALENTIC_PROXY_URL=
EXPO_PUBLIC_GEMINI_API_KEY=
EXPO_PUBLIC_ANTHROPIC_API_KEY=

Precedence: proxy > Gemini > Anthropic > offline fallback. The default model is gemini-3.6-flash, set in app.json under expo.extra with the other non-secret knobs (reminder lead, confidence floor). With no key the app runs the offline rule-based extractor and the Setup tab says so.

Anything prefixed EXPO_PUBLIC_ is inlined into the JS bundle and is extractable from the APK. Fine for a demo on your own device; not fine for a release — that is what the proxy variable is for, and it wins when both are set.

Notification access: Setup → Grant access. Android has no permission dialog for it, so this opens Settings → Notifications → Device & app notifications. The header dot turns green once it is granted and the service has bound.

Android Studio: npm run prebuild writes android/ from app.json + modules/; then open android — the Gradle root, not the repo root. android/ is generated and gitignored, so do not hand-edit it; native config belongs in app.json or modules/calentic-listener/. A local build needs JDK 17 plus the Android SDK; EAS Build needs neither.


Status and known gaps

Filter + Hinglish date resolververified in plain Node: 10/10 date cases, 8/8 plans caught, 0 false positives
Pipeline, UI, confirm sheet, Expo module, Kotlin listenerwritten
JS bundlesverified — 1.7 MB, exit 0, pipeline confirmed present
Kotlin compiled · running on a device · live Claude callNOT DONE
  • Nothing native has been compiled. Expect Kotlin and Gradle fixes on the first eas:dev or run:android.
  • No live Claude call has ever been made. The cost-per-notification figure is computed from list pricing, not measured. Set a key and watch the Live tab.
  • Background pipeline. The listener buffers up to 200 notifications while no JS listener is attached, then drops the oldest. Notifications arriving while the app is fully stopped are processed on next launch, not in the background — real background handling needs a foreground service or a headless task.
  • The confirm sheet only appears in the foreground. For a real product it should be a full-screen intent or an actionable notification.
  • USE_EXACT_ALARM is declared for reminders. Google Play restricts it to apps whose core function is alarms/reminders; if policy pushes back, drop to SCHEDULE_EXACT_ALARM.

Privacy: what actually leaves the device

The regex filter is a cost control, not a privacy mechanism. Being precise about this matters for what the pitch can claim.

  • Notifications the filter rejects never leave the device.
  • Notifications that pass go to the model in full — app, sender name, and complete message body. On the demo script that is 8 of 18 (44%).
  • The filter forwards precisely the messages carrying the most sensitive detail (who, when, where) and discards the harmless chatter.
  • Every notification body, including rejected ones, is written to plaintext SQLite with no retention limit.

Storage is local-first, but the cloud call is not opt-in — it is the core of the pipeline, so any "local-first, cloud sync is opt-in" claim needs rewording. Fixes worth making: stop storing rejected bodies (log a verdict and hash instead), prune notifications after N days, strip the sender name before the call and re-attach it locally, and make cloud extraction a real toggle with offline as the default.


The Electron desktop client built alongside this was working and verified, but it was never committed to git — so C:\Work\calentic-desktop-archive\ is the only copy.

Contributors

shibammandal

7 commits

Languages

JavaScript

86.3%

Jupyter Notebook

9.3%

Kotlin

3.0%

Python

1.1%