Background
Most productivity tools focus on task management: lists, checkboxes, due dates. But I wanted something different: a tool that captures intent vs. reality across days and uses that delta to surface behavioral patterns.
The concept is simple. Every morning, you write what you plan to do. Every evening, you write what actually happened. Over time, the gap between those two entries reveals your real patterns: recurring blockers, energy cycles, follow-through tendencies. Things you can't see in a single day but that become obvious across weeks.
The core requirements:
Plan vs. Reality
Capture morning intent and evening reflection as a paired daily ritual, not a task list.
Pattern Detection
Use AI to analyze 14 days of history and surface non-obvious behavioral patterns the user wouldn't notice themselves.
Accountability Metrics
Track streaks, follow-through rate (% of mornings that get an evening reflection), and mood trends over time.
Shareable Digests
Generate weekly AI summaries with wins, patterns, and blocker analysis, shareable via a public link without exposing sensitive data.
Architecture
The system is a decoupled monorepo: a Rails API backend and a React SPA frontend, communicating over a typed REST API with JWT auth and automatic timezone synchronization on every request.
React SPA
X-Timezone · Bearer JWT
Rails 8.1 API
PostgreSQL
Users, check-ins, digests
OpenAI
Nudge, summary, digest
Public Share
Token-based, no auth
The backend is a Rails 8.1 API-only app with no views and no asset pipeline. The frontend is a Vite-powered React 19 SPA with TypeScript. They're fully decoupled: the frontend sends X-Timezone on every request so the server always knows the user's local “today,” and JWT tokens are stored in localStorage. The AI layer is a single service class that talks to OpenAI's chat completions API via raw Net::HTTP. No SDK, no background jobs, no queues.
Check-In System
The check-in system is the core of the app. It enforces a simple but strict contract: one morning check-in and one evening reflection per calendar day per user.
Data Model
Rather than separate models for morning and evening entries, I used a single checkins table with an enum. This was the key design decision. It keeps queries simple and lets the database enforce the business rule:
1class Checkin < ApplicationRecord2 belongs_to :user34 enum :checkin_type, { morning: 0, evening: 1 }56 validates :checkin_type, presence: true7 validates :date, presence: true8 validates :feeling, inclusion: { in: 0..10 }, allow_nil: true9 validates :checkin_type, uniqueness: { scope: [ :user_id, :date ],10 message: "already exists for this date" }1112 validates :today_plan, presence: true, if: :morning?13 validates :what_happened, presence: true, if: :evening?1415 scope :for_date, ->(date) { where(date: date) }16 scope :for_range, ->(start_date, end_date) { where(date: start_date..end_date) }17 scope :mornings, -> { where(checkin_type: :morning) }18 scope :evenings, -> { where(checkin_type: :evening) }19 scope :recent, -> { order(date: :desc, checkin_type: :asc) }20end
The composite unique index on [user_id, date, checkin_type] enforces “one morning, one evening per day” at the database level, not just in application validation. Conditional presence validators (today_plan required for mornings, what_happened required for evenings) mean each check-in type collects the right fields without separate models or STI.
Timezone Sync
One of the trickiest parts of a daily check-in app is answering: “What day is it?” If the user is in Tokyo and the server is in UTC, “today” is a different date. I solved this by auto-syncing the user's timezone on every authenticated request:
1def current_date2 Time.use_zone(user_timezone) { Time.zone.today }3rescue ArgumentError4 Date.today5end67def sync_timezone_from_header8 tz = request.headers["X-Timezone"]9 return if tz.blank? || @current_user.nil?10 return if tz == @current_user.timezone1112 Time.use_zone(tz) { nil } # validate the timezone identifier13 @current_user.update_column(:timezone, tz)14rescue ArgumentError15 # invalid timezone identifier, ignore16end
The frontend detects the browser's IANA timezone via Intl.DateTimeFormat and attaches it as an X-Timezone header on every API call. update_column intentionally skips validations and callbacks. This is a high-frequency, low-risk write; the timezone just silently stays in sync.
Morning / Evening Flow
A single today endpoint returns both morning and evening check-ins for the user's current local date. The frontend uses this to determine which cards are filled, which are waiting for input, and whether to show the “day complete” state.
AI Coach
The AI layer is built as a single service class (AiService) that handles three distinct operations: post-check-in nudges, daily summaries, and weekly digests. All use OpenAI's GPT-4o-mini via raw HTTP. No SDK, no abstractions.
Nudge Engine
The nudge is the most user-facing AI feature. After each check-in, the frontend fires a request to generate a one-sentence observation based on the user's recent history:
1def self.generate_nudge(user, current_checkin)2 recent_checkins = user.checkins3 .where("date >= ?", 14.days.ago.to_date)4 .order(date: :desc, checkin_type: :asc)5 .limit(30)67 return nil if recent_checkins.size < 389 name = user.name10 prompt = build_nudge_prompt(recent_checkins, current_checkin, name)11 response = chat(prompt, system: "You are a blunt, slightly quirky coach " \12 "talking to #{name}. Speak in second person (you/your), be specific " \13 "and honest, and let your personality show with things like :) or a " \14 "dry observation. No sugarcoating, no generic advice, no em dashes. " \15 "Respond in JSON only.")1617 parsed = JSON.parse(response)18 nudge_text = parsed["nudge"]19 nudge_text.present? && nudge_text != "null" ? nudge_text : nil20rescue JSON::ParserError, StandardError21 nil22end
Key design decisions: the nudge requires at least 3 recent check-ins before activating, since pattern-matching on a single data point is not useful. The prompt explicitly instructs the model to find non-obvious patterns and avoid echoing what the user just wrote:
1<<~PROMPT2 Here's #{user_name || 'this person'}'s standup history for the last3 2 weeks and their check-in today. Spot a NON-OBVIOUS pattern and4 call it out directly.56 Return a JSON object with:7 - "nudge": One blunt sentence (max 25 words) addressed directly to8 them using "you". Surface something they likely haven't noticed —9 recurring blockers, feeling trends, forgotten goals, day-of-week10 dips, plan-vs-reality gaps. If there's genuinely nothing worth11 saying, return "nudge": null.1213 Rules:14 - Don't echo what they just said. They know what they wrote.15 - No cheerleading. No "Great job!" Be specific and direct.16 - Reference real data from their history.17 - Work AND personal life are fair game.18PROMPT
The nudge is best-effort. If OpenAI fails, parsing fails, or there's not enough history, it silently returns nil and the frontend just doesn't show anything.
Daily Summaries
Daily summaries take a morning/evening pair and produce a structured coach diagnosis. The prompt asks for a specific JSON contract, and the output is formatted into a four-part framework:
Reality check
One blunt sentence about the most important thing today
Focus now
The single priority that actually matters
First step
A tiny concrete action doable in 5–10 minutes
Fallback
"If X happens, then do Y" as a safety net
Every field has a sensible fallback, so even if the model returns a partial or malformed response, the user always sees actionable output.
Weekly Digests
Weekly digests aggregate a full week of check-ins plus daily summaries into a narrative recap. The prompt packs each day's morning/evening data, feelings, and AI insights into a structured format, then asks for wins, patterns, and blocker analysis.
The critical design decision: the controller computes server-side metrics independently of the AI response.
1feelings = checkins.filter_map(&:feeling)2morning_dates = checkins.select(&:morning?).map(&:date).uniq3evening_dates = checkins.select(&:evening?).map(&:date).uniq4paired = (morning_dates & evening_dates).size56{7 ai_digest: parsed["digest"],8 wins: parsed["wins"],9 patterns: parsed["patterns"],10 blocker_patterns: parsed["blocker_patterns"],11 avg_energy: feelings.any? ? (feelings.sum.to_f / feelings.size).round(0) : nil,12 completion_rate: morning_dates.any? ? (paired.to_f / morning_dates.size * 100).round(0) : nil13}
Average energy and completion rate are calculated from raw check-in data, not from the model's output. The AI provides narrative analysis, but the hard metrics are computed deterministically. You never want the model deciding what your completion rate is.
Dashboard
The dashboard gives users an at-a-glance view of their accountability data through four bento-style stat cards and two time-series visualizations.
Streak Algorithm
Streak calculation loads all distinct check-in dates in a single query, then walks backwards from today in Ruby:
1def calculate_streak(checkins, from_date)2 dates_with_checkins = checkins3 .where(date: (from_date - 365.days)..from_date)4 .distinct.pluck(:date)5 .sort6 .reverse78 streak = 09 expected = from_date10 dates_with_checkins.each do |d|11 break if d != expected12 streak += 113 expected -= 1.day14 end1516 streak17end
This avoids N+1 queries. One pluck fetches up to 365 dates as an array, then the streak is a linear O(n) walk. The 365-day window caps the query scope without limiting realistic streak lengths.
Follow-Through Metric
“Follow-through” is the percentage of mornings that also have a matching evening reflection on the same date. It's the core accountability metric. It answers “when you set an intention, did you close the loop?”
1morning_count = checkins.mornings.count2paired_days = checkins.mornings3 .where(date: checkins.evenings.select(:date))4 .select(:date).distinct.count5completion_rate = morning_count > 0 ?6 (paired_days.to_f / morning_count * 100).round(0) : 0
The query uses a subquery to find mornings whose dates also appear in the evenings set. This runs as a single SQL query, not two separate fetches.
Mood Trends
Feeling data is aggregated per-day and rendered as a Recharts area chart with a gradient fill. Below it, a heatmap shows ternary completion state: none, partial (morning only), or complete (both check-ins). The backend computes all of this in just two queries: group(:date).average(:feeling) for mood data and group(:date, :checkin_type).count for completion flags, then builds the daily array in Ruby.
Frontend
Card Flip Interaction
The main Today page uses a 3D card flip interaction for morning and evening check-ins. Each card has a front face (a tilted image card with a daily prompt) and a back face (the check-in form or completed entry).
Framer Motion's layoutId enables a seamless animation from the signed-out state (where the cards float at angles as peek previews) to the signed-in state (where they become full interactive panels). Daily prompts rotate based on the day index for variety without randomness.
The mood selector uses a custom 7-point glyph system where each level has a hand-drawn SVG icon (raindrop for “drained,” flame for “energized,” sun for “great”) instead of a numeric slider. People relate to visual metaphors better than numbers on a scale.
Horizontal Timeline
History uses a horizontal scroll layout where today is leftmost and you scroll right into the past. As you scroll, the container uses elementFromPoint to detect which month the viewport center is in, and a floating month overlay fades in (“March 2026”) then auto-hides after 1.8 seconds of no change. The entire 90-day range loads in a single API call to avoid pagination complexity.
Visual Layer
The app uses a layered visual stack for atmosphere. All components are custom, built without any component library:
Full-screen shader with animated noise, hue shifting, and warp distortion
Soft glow that follows the mouse cursor across the viewport
Physics-based tilt on hover using spring-driven rotateX/Y
Character-by-character text reveal animations for greetings
Fanned card layout with hover-to-front interaction for digests
Animated gradient shine effect on mood labels
Auth & Sharing
JWT Flow
Authentication uses a minimal JWT implementation: no sessions, no cookies, no refresh tokens. Tokens expire after 7 days, stored in localStorage and sent as a Bearer header. On 401 responses, the API client automatically clears the token and forces re-auth.
Pages like History, Dashboard, and Weekly show a LockedOverlay when the user isn't authenticated: a blurred preview with a sign-in prompt rather than a redirect. The Today page is always accessible, showing a marketing-style welcome state with tilted peek cards.
Public Digest
Weekly digests have a shareable public endpoint that requires no authentication. The share URL uses a unique token per digest (not per user), and intentionally omits blocker_patterns. Wins and patterns are shareable, but blockers are private. Only the user's name is exposed, no email or other account data.
Database
The schema is designed around the daily check-in lifecycle with four tables:
Tables
- →
users: email (unique, indexed), bcrypt password digest, per-user share token, timezone (default UTC, auto-synced from browser) - →
checkins: morning/evening enum, structured text fields (today_plan, what_happened, blockers, carry_over), feeling (0-10 integer), composite unique index on [user_id, date, checkin_type] - →
daily_summaries: AI-generated summary per user per day, task counts, carry-overs, unique on [user_id, date] - →
weekly_digests: AI digest text, computed metrics (avg_energy, completion_rate), wins/patterns/blocker_patterns, per-digest share token, unique on [user_id, week_start]
Every unique constraint is enforced at the database level, not just in Rails validations. All foreign keys are explicit. Text fields are validated to 2000 characters max. The schema evolved iteratively. Early versions used a single body field; structured fields were added in a later migration while keeping body nullable for backward compatibility.
Deployment
The backend uses a production-ready multi-stage Dockerfile with jemalloc for memory optimization. The entrypoint runs db:prepare before starting the server, handling both initial setup and migrations on deploy. The frontend builds to a static Vite bundle with CORS configured via environment variable.
The AI layer degrades gracefully when OPENAI_API_KEY is absent. AiService.chat returns a static JSON fallback string so all downstream parsers still receive valid JSON. The app is fully functional without AI; the features just show placeholder content.
Results
Concept to deployed product in 36 hours of focused solo work.
< 1 min
Daily check-in time
14 days
AI context window
36 hrs
Concept to deploy
In Hindsight
The most useful decision was modeling check-ins as morning/evening pairs in a single table. One enum, one composite index. That one choice made everything downstream trivial: streaks, follow-through, AI prompts that compare what you planned to what actually happened.
“Constraining what the model shouldn't do matters more than what it should.”
The nudge prompt went through several iterations. The breakthrough was adding explicit negative constraints (“Don't echo what they just said,” “No cheerleading,” “No generic advice”) and gating on a minimum of 3 check-ins before the AI activates at all.
The other thing I didn't expect: the visual polish ended up being load-bearing. WebGL shaders, physics-based card tilts, custom SVG mood glyphs. For a tool you're supposed to open every morning and every evening, the aesthetic is what turns it from a form into a ritual.
What's Next
The main gap right now is that the app relies on you remembering to check in. Adding push notifications for morning/evening reminders would close that loop. Beyond that, wrapping the SPA as a PWA for home screen installation is the obvious next step for a daily-use tool.
