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.
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.
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
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
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.
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.
| Outcome | What happens |
|---|---|
| Accept | promoted in place to confirmed — same row, same id. Joins the list, writes to the calendar, arms the reminder. |
| Dismiss | the row is deleted. The only action that throws a plan away. |
| Ignore | the 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.
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 delete | the row becomes cancelled: out of the list, still recoverable. If the sender was wrong, you need it back. |
| A disputed task looks disputed | a "change requested" marker on the row, so you do not walk into the cancelled lab with the question unanswered. |
| Urgent proposals escalate | against something starting within three hours it also goes to the notification shade, with Keep it / Apply. |
| A sender repeating themselves is not news | one open proposal per task per kind. |
| Withdrawn before you answered | if 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 plan | reading "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.
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.
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:
| Change | What follows |
|---|---|
| the time | the 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 duration | the end moves, the start does not |
| to all-day | the clock is dropped, the date is kept, the end time goes |
| the title or place | the calendar entry is updated; the reminder is left alone |
| into the past | the 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.
| Bucket | What it holds |
|---|---|
| Overdue | start time passed, never ticked off |
| Today | happening today, still open |
| Upcoming | later than today, still open |
| Done | ticked 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.
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.
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.
| Filter + Hinglish date resolver | verified in plain Node: 10/10 date cases, 8/8 plans caught, 0 false positives |
| Pipeline, UI, confirm sheet, Expo module, Kotlin listener | written |
| JS bundles | verified — 1.7 MB, exit 0, pipeline confirmed present |
| Kotlin compiled · running on a device · live Claude call | NOT DONE |
eas:dev or run:android.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.The regex filter is a cost control, not a privacy mechanism. Being precise about this matters for what the pitch can claim.
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.
7 commits
JavaScript
86.3%
Jupyter Notebook
9.3%
Kotlin
3.0%
Python
1.1%
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.
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.
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
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
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.
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.
| Outcome | What happens |
|---|---|
| Accept | promoted in place to confirmed — same row, same id. Joins the list, writes to the calendar, arms the reminder. |
| Dismiss | the row is deleted. The only action that throws a plan away. |
| Ignore | the 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.
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 delete | the row becomes cancelled: out of the list, still recoverable. If the sender was wrong, you need it back. |
| A disputed task looks disputed | a "change requested" marker on the row, so you do not walk into the cancelled lab with the question unanswered. |
| Urgent proposals escalate | against something starting within three hours it also goes to the notification shade, with Keep it / Apply. |
| A sender repeating themselves is not news | one open proposal per task per kind. |
| Withdrawn before you answered | if 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 plan | reading "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.
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.
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:
| Change | What follows |
|---|---|
| the time | the 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 duration | the end moves, the start does not |
| to all-day | the clock is dropped, the date is kept, the end time goes |
| the title or place | the calendar entry is updated; the reminder is left alone |
| into the past | the 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.
| Bucket | What it holds |
|---|---|
| Overdue | start time passed, never ticked off |
| Today | happening today, still open |
| Upcoming | later than today, still open |
| Done | ticked 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.
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.
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.
| Filter + Hinglish date resolver | verified in plain Node: 10/10 date cases, 8/8 plans caught, 0 false positives |
| Pipeline, UI, confirm sheet, Expo module, Kotlin listener | written |
| JS bundles | verified — 1.7 MB, exit 0, pipeline confirmed present |
| Kotlin compiled · running on a device · live Claude call | NOT DONE |
eas:dev or run:android.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.The regex filter is a cost control, not a privacy mechanism. Being precise about this matters for what the pitch can claim.
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.
7 commits
JavaScript
86.3%
Jupyter Notebook
9.3%
Kotlin
3.0%
Python
1.1%