A local-first tracker for things that make more sense as time since the last occurrence than as fixed calendar events.
Live on the Apple App Store.
iOS App Store · Web App · Kiera McCabe on the App Store
Started: December 2024
Time Since began as a practical tracker for chores and recurring life events that are easier to manage by elapsed time than fixed calendar dates. The current Expo app is a later cross-platform implementation of that workflow.
Time Since can track chores, maintenance, routines, habits, timed sessions, and long-running life dates without requiring an account or cloud service.
Create a tracker and choose its type:
| Type | Best for | Main behavior |
|---|---|---|
| Occurrence | Chores, maintenance, routines, one-off events | Log each occurrence and measure time between logs |
| Session | Fasting, workouts, study, focused work | Start, pause, resume, and end a timed session |
| Counter | Anniversaries, move dates, deadlines, lease dates | Count since a start date or toward a target date |
Give the tracker a name and category. Occurrence trackers also inherit the current default reminder interval when they are created; changing the app-wide default later does not rewrite existing tracker reminders.
For something like Litterbox or Change bed:
Session trackers are for activities with a duration rather than a single timestamp.
Counters are date-based rather than completion-based.
Trackers are grouped by category. In Settings → Swipe Actions, each horizontal direction can be configured as:
This lets the main list stay fast for repetitive logging while still keeping detailed entry available when needed.
Native mobile reminders apply to occurrence trackers.
The browser version is useful for the UI, local storage, PWA use, and data portability, but native notification behavior should be tested on iOS/Android.
CSV is the app’s visible interchange format. There is no account or automatic cloud sync: moving data between browsers/devices is intentionally manual.
time-since-export.csv.The current native interface exposes the export as copyable text rather than automatically writing a file.
Before using Overwrite or Replace all, export your current data first.
| Mode | What it does |
|---|---|
| New only | Keeps existing tracker settings/history, adds trackers that do not exist yet, and adds nonduplicate imported history to both new and matching trackers. |
| Overwrite | For matching trackers, replaces their tracker fields and existing occurrence/session history with the imported version. Unrelated local trackers remain untouched. |
| Replace all | Replaces the current local tracker/history dataset with the imported CSV. App-level settings are rebuilt from defaults because CSV does not contain those settings. |
Trackers match on the exact combination of name + category + type.
Duplicate history is skipped using:
For straightforward one-row-per-event history, use this header:
Category,Event,Occurrence,Note
Household,Litterbox,2026-08-18T09:15:00,Cleaned both boxes
Household,Change bed,2026-08-12 21:30:00,
Maintenance,Air filter,2026-08-01T12:00:00,Hallway unit
Required fields:
CategoryEventOccurrenceNote is optional.
The simple format does not carry explicit session/counter structures, reminder settings, or counter targets. Because there is no type column, Time Since infers an obvious tracker semantic from the event name when possible; otherwise it imports the tracker as an occurrence tracker.
A Time Since export starts with:
kind,category,name,type,date,note,reminder_days,duration_format,counter_start,counter_target
The full format can represent:
Dates may be ISO-style timestamps or legacy local timestamps such as 2026-08-18 09:15:00.
CSV is intended for portable tracker/history data, not a byte-for-byte application backup.
Category,Event,Occurrence,Note format cannot encode sessions or counters explicitly.Time Since is local-first.
Because there is no automatic cross-device sync, exporting a CSV periodically is the safest way to keep a portable copy of your history.
Google Calendar integration is not implemented. There are no Google OAuth, calendar API, or expo-calendar code paths in the current app.
Use the checked-out repository as the source of truth for local development. Separate working copies should not be treated as canonical unless they have been explicitly promoted.
npm install
npm run web
For mobile testing:
npm run ios
npm run android
npm install
npm run build:web
The static web build is written to dist/ and can be deployed to Vercel or Netlify.
Framework Preset: Other
Install Command: npm install
Build Command: npm run build:web
Output Directory: dist
The repo includes vercel.json with the production build/output settings.
Swipe feel is separate from swipe actions:
src/swipe-tuning.ts — how hard you have to swipe. This section.src/swipe-config.ts — which action each direction runs and which tracker types are swipeable at all. Counters are never swipeable, because a counter has nothing to log.A row commits on either of two independent paths, so there are two sets of knobs rather than one threshold:
committed = travel >= commitDistance // slow, deliberate drag
|| (travel >= flickMinDistance && speed >= flickVelocity) // fast flick
All native gesture feel lives in one object, SWIPE_TUNING in src/swipe-tuning.ts, so the recognition logic in components/TrackerRow.tsx never has to be edited to change how the gesture feels. Distances are React Native points; flickVelocity is PanResponder’s vx, roughly screen widths per second.
| Knob | Default | Effect |
|---|---|---|
activationDistance |
7 | Horizontal travel before a row claims the gesture. Lower = easier pickup. |
directionRatio |
1.35 | How far horizontal movement must dominate vertical. Raise it if scrolling gets mistaken for swiping. |
commitDistance |
64 | Travel at which a slow, deliberate swipe runs the action. |
flickMinDistance |
18 | Minimum travel before a fast flick is allowed to count at all. |
flickVelocity |
0.55 | Speed at which a short flick commits without reaching commitDistance. |
maxTranslation |
112 | How far the row can visually slide while being dragged. |
springTension |
135 | Snap-back speed. Higher = faster. |
springFriction |
14 | Snap-back bounce. Higher = less bounce. |
Starting points for the usual complaints:
| Symptom | Change |
|---|---|
| Swipes fire accidentally while scrolling the list | Raise directionRatio (1.35 → 1.8), then activationDistance (7 → 12) |
| Swiping feels like hard work | Lower commitDistance (64 → 48) and flickVelocity (0.55 → 0.40) |
| Taps get misread as flicks | Raise flickMinDistance (18 → 26) |
| Row snaps back too abruptly | Lower springTension (135 → 100), raise springFriction (14 → 18) |
Change one knob at a time and retest on a device — the simulator’s trackpad flicks are much faster than a thumb, so flickVelocity in particular does not feel the same in both places.
A Debug build reloads over Metro, so saving the file is enough to see the change. Then:
npm test # tests/swipe-config.test.js compiles src/ and asserts gesture behavior
npm run typecheck
TrackerRow’s PanResponder returns false whenever Platform.OS === 'web', so the browser runs its own pointer-event recognizer. The two are separate implementations that are deliberately kept at matching values, so a tuning change belongs in both places or they drift apart. Web constants sit at the top of the IIFE in web-demo/mobile-gesture-import-fixes.js:
| Constant | Default | Native counterpart |
|---|---|---|
ACTIVATION_PX |
7 | activationDistance |
DIRECTION_RATIO |
1.35 | directionRatio |
COMMIT_PX |
64 | commitDistance |
FLICK_MIN_PX |
18 | flickMinDistance |
FLICK_VELOCITY_PX_PER_MS |
0.55 | flickVelocity |
MAX_TRANSLATE_PX |
112 | maxTranslation |
SCROLL_PX |
18 | vertical travel that concedes the gesture to the scroller |
SCROLL_RATIO |
1.5 | vertical must dominate by this much to concede |
TAP_SLOP_PX |
7 | movement still counted as a tap |
TAP_MAX_MS |
700 | longest press still counted as a tap |
EDGE_START_PX |
28 | edge-swipe back: how close to the edge the gesture must start |
EDGE_COMMIT_PX |
64 | edge-swipe back: travel required to navigate |
The units are not identical even though the numbers are: web measures CSS pixels and px/ms, React Native measures points and PanResponder vx. They are matched because they represent the same physical interaction, not because the scales are the same — so port a change by intent, not by copying a number blindly.
Keep TAP_SLOP_PX equal to ACTIVATION_PX. If slop is the smaller of the two, there is a dead band of movement that registers as neither a tap nor a swipe.
Web has the same two commit paths as native — absX >= COMMIT_PX, or a flick clearing both FLICK_MIN_PX and FLICK_VELOCITY_PX_PER_MS — so the symptom table above applies to the browser too.
npm run build:web # regenerates dist/ from web-demo/; editing dist/ directly is overwritten
npm test # tests/web-swipe-gestures.test.js drives pointer events under jsdom