2 Daily Word : a Bible reading plan application in Rock Shared by Sam DeSocio, Good News Church 13 days ago 18.4 CMS, Connection, Engagement, General, Reminders Intermediate I love YouVersion and other ways of selecting Bible reading plans but what I hadn't found was a way to allow people to craft their own Bible reading plan. I had found, working with another project, that it was really easy to use API.Bible and so I decided to see if I could simply make an app that allows people to create their own Bible reading plans. If they can create their Bible reading plans, why not read/listen to them and if that's the case then why not allow for some sort of streak and encouragement about reading those plans? Here are the parts: Plan builder — start from a template (whole Bible, M'Cheyne, New Testament, Psalms & Proverbs, and others), or compose your own from presets and individual books, each with its own frequency (once a year through weekly). Two reading rhythms per section — steady (spread evenly across the year) or bunched (read straight through, rest, repeat). Switching mid-year only changes future days; days already scheduled never move. Today view — the day's passages in canonical Bible order, check-offs, progress bar, streak and year-to-date counts, and a catch-up list of missed readings. Reader — full passage text with adjustable type size. Audio with follow-along — plays the day's chapters and highlights the verse being read, with speed control and a chapter queue. Year calendar and full printable plan — a self-paginating three-column landscape print sheet. Year-complete screen — after day 365, a summary of the year with the option to begin a new one. Reporting data on the person record — eight person attributes, updated on every save, ready for data views and workflows. Before you start A Rock version that includes Lava Applications. Check for Admin Tools → CMS Configuration → Lava Applications. If that page does not exist, your Rock version predates the feature and this recipe will not work. Administrator access, including the ability to create person attributes and to enable the SQL Lava command on an endpoint. An API.Bible key (https://scripture.api.bible). Free developer keys exist; see Step 6 for the licensing and rate-limit considerations, which are the single most important planning decision in this recipe. The template file — daily-word.lava, attached to this recipe. It is a single self-contained file: HTML, CSS, and JavaScript, with no external dependencies other than Google Fonts and the Bible API. Step 1 — Create the person attributes Go to Admin Tools → General Settings → Person Attributes and create all eight. Every one uses Entity Type: Person. Set Edit security to no one — the application writes these on the person's behalf through the endpoint, and they are not meant to be edited by hand. Key Field Type Purpose DailyWordPlan Memo The plan itself: sections, start date, check-offs, reading history, preferences. Stored as JSON. DailyWordShare Boolean Whether the person consents to their reading history being visible to staff. Leave the attribute's Default Value blank — a blank default means people who never used the app are not counted as having consented. DailyWordStreak Integer Current streak at last save. DailyWordBestStreak Integer Longest streak ever reached. Only ever increases. DailyWordDaysRead Integer Total days on which the person read anything, for the life of their account. DailyWordDaysReadYear Integer Days read in the current calendar year. DailyWordDaysReadYearOf Integer Which calendar year the previous number refers to. DailyWordLastRead Date The most recent day on which the person read anything. Security on each attribute: DailyWordPlan — View: All Authenticated Users. The application reads this one in the browser, so signed-in people must be able to see their own value. All others — View: staff only (whichever role you use for pastoral data). The names above are used verbatim by the template and the endpoint. If you rename any of them, you must change them in both places. Why DailyWordDaysReadYearOf exists: if someone stops reading in November, their DailyWordDaysReadYearvalue freezes and is still sitting there the following March, looking like a current-year number. Pairing the two fields in a data view (DaysReadYear >= 50 AND DaysReadYearOf = 2026) avoids reporting stale totals. Step 2 — Create the Lava Application Admin Tools → CMS Configuration → Lava Applications → Add Name: Daily Word Slug: daily-word Active: yes This is where I let Claude do its magic. The slug becomes part of the endpoint URL, so if you choose something different you must update the save URL constant in the template (see Step 5). Step 3 — Create the save endpoint Inside the Daily Word application, add an endpoint: Name: Save Plan Slug: save-plan Method: POST Enabled Lava Commands: Sql (required — the endpoint writes attribute values) Security Mode: Application View Paste the following as the endpoint's Lava: {% comment %} Daily Word — save-plan endpoint. Expects: { "plan": "<json string>", "streak": <int> } Returns: { "ok": true, "readDates": [ ...merged dates... ] } Three things worth knowing about what this does: 1. It merges the reading-history dates from the incoming plan with those already stored, rather than overwriting. Plans are saved as one whole blob, so a second device or a stale browser tab would otherwise be able to erase days the person really did read. 2. The summary counts are derived from the merged set, so they can only grow, and the best-streak value is never lowered. 3. Attributes that do not exist match no row and are silently skipped, so you can create them in any order, or omit ones you do not want. {% endcomment %} {% if CurrentPerson %} {% assign todayLocal = 'Now' | Date:'yyyy-MM-dd' %} {% assign clientStreak = Body.streak | AsInteger | Default:0 %} {% sql pid:'{{ CurrentPerson.Id }}' incoming:'{{ Body.plan }}' streak:'{{ clientStreak }}' today:'{{ todayLocal }}' %} SET NOCOUNT ON; DECLARE @petid int = (SELECT Id FROM EntityType WHERE [Name] = 'Rock.Model.Person'); DECLARE @existing nvarchar(max) = ( SELECT av.Value FROM AttributeValue av JOIN Attribute a ON a.Id = av.AttributeId WHERE a.[Key] = 'DailyWordPlan' AND a.EntityTypeId = @petid AND av.EntityId = @pid); -- union of stored + incoming reading dates, sorted, deduplicated DECLARE @mergedDates nvarchar(max); SELECT @mergedDates = '[' + ISNULL( STRING_AGG(CAST('"' + v + '"' AS nvarchar(max)), ',') WITHIN GROUP (ORDER BY v), '') + ']' FROM ( SELECT DISTINCT [value] AS v FROM ( SELECT [value] FROM OPENJSON(ISNULL(JSON_QUERY(@existing, '$.readDates'), '[]')) UNION SELECT [value] FROM OPENJSON(ISNULL(JSON_QUERY(@incoming, '$.readDates'), '[]')) ) u WHERE [value] IS NOT NULL AND LEN([value]) = 10 ) x; DECLARE @newPlan nvarchar(max) = JSON_MODIFY(@incoming, '$.readDates', JSON_QUERY(@mergedDates)); DECLARE @daysRead int = (SELECT COUNT(*) FROM OPENJSON(@mergedDates)); DECLARE @yr char(4) = LEFT(@today, 4); DECLARE @daysYear int = (SELECT COUNT(*) FROM OPENJSON(@mergedDates) WHERE [value] LIKE @yr + '-%'); DECLARE @lastRead nvarchar(10) = (SELECT MAX([value]) FROM OPENJSON(@mergedDates)); DECLARE @share nvarchar(5) = CASE WHEN JSON_VALUE(@incoming, '$.sharePublicly') = 'true' THEN 'True' ELSE 'False' END; DECLARE @bestExisting int = TRY_CAST(( SELECT av.Value FROM AttributeValue av JOIN Attribute a ON a.Id = av.AttributeId WHERE a.[Key] = 'DailyWordBestStreak' AND a.EntityTypeId = @petid AND av.EntityId = @pid) AS int); DECLARE @best int = CASE WHEN ISNULL(@bestExisting, 0) > @streak THEN @bestExisting ELSE @streak END; DECLARE @vals TABLE ([Key] nvarchar(60), Val nvarchar(max)); INSERT INTO @vals VALUES ('DailyWordPlan', @newPlan), ('DailyWordShare', @share), ('DailyWordStreak', CAST(@streak AS nvarchar(20))), ('DailyWordBestStreak', CAST(@best AS nvarchar(20))), ('DailyWordDaysRead', CAST(@daysRead AS nvarchar(20))), ('DailyWordDaysReadYear', CAST(@daysYear AS nvarchar(20))), ('DailyWordDaysReadYearOf', @yr), ('DailyWordLastRead', ISNULL(@lastRead, '')); UPDATE av SET Value = v.Val, IsPersistedValueDirty = 1, ModifiedDateTime = GETDATE() FROM AttributeValue av JOIN Attribute a ON a.Id = av.AttributeId JOIN @vals v ON v.[Key] = a.[Key] WHERE a.EntityTypeId = @petid AND av.EntityId = @pid; INSERT INTO AttributeValue (IsSystem, AttributeId, EntityId, Value, [Guid], IsPersistedValueDirty, CreatedDateTime) SELECT 0, a.Id, @pid, v.Val, NEWID(), 1, GETDATE() FROM Attribute a JOIN @vals v ON v.[Key] = a.[Key] WHERE a.EntityTypeId = @petid AND NOT EXISTS (SELECT 1 FROM AttributeValue av WHERE av.AttributeId = a.Id AND av.EntityId = @pid); SELECT @mergedDates AS mergedDates; {% endsql %} {% assign row = results | First %} {"ok":true,"readDates":{{ row.mergedDates }}} {% else %} {"ok":false,"error":"not signed in"} {% endif %} A note on GETDATE(): on some hosted SQL platforms GETDATE() returns UTC rather than your organisation's local time. That is why the current date is passed in from Lava as @today rather than computed in SQL. The ModifiedDateTime stamps above are cosmetic and unaffected. Step 4 — Create the page and block Create a page where the application will live. An internal page is the simplest starting point; an external site page works equally well. Add a Lava Application Content block to it. In the block settings, point it at the Daily Word application. Enabled Lava Commands on the block: none. The block only renders the interface; all data writing happens through the endpoint, which carries its own SQL permission. Step 5 — Add the application template Open the block's Lava content and paste in the contents of daily-word.lava. Before saving, set the two constants near the top of the JavaScript: var DW_SAVE_URL = "/api/v2/lava-app/1/daily-word/save-plan"; var DW_BIBLE_KEY = "your-api-bible-key-here"; In the save URL, the 1 is a fixed part of Rock's Lava Application API route — it is not an application ID and should not be changed. The daily-word segment is your application's slug and the save-plan segment is your endpoint's slug; change those only if you named them differently. If you are writing your own calls to a Lava Application endpoint, note that Rock requires the header X-Helix-CSRF-Protection: true on every request. Without it the endpoint returns 401, which is indistinguishable from a permissions failure. The template already sends it. Step 6 — Get a Bible API key, and choose your tier deliberately Text and audio come from API.Bible. Register at https://scripture.api.bible, then: Choose a Bible your key is licensed for. Set TEXT_BIBLE in the template to that Bible's ID. Different keys carry different licences; a Bible ID that works for one organisation may return errors for another. Check the audio Bible situation. The application discovers an English audio Bible automatically and hides the audio player if none is available, so audio is optional — the reader works without it. Plan for rate limits before you launch. This is the point where deployments fail. A free developer tier in the region of a few thousand calls per month is consumed quickly: each chapter of text is one call, each audio chapter is another, and a single reader with a four-passage plan can use several calls a day. Twenty daily users will exhaust a small free allowance within days. The application caches chapter text in the browser for 30 days and edition discovery for 7, which reduces repeat calls substantially, but it cannot reduce first reads. Estimate your expected users against your allowance before you invite a congregation. Security note about the key. The key sits in the page's JavaScript and is therefore visible to anyone who views source. For a read-only Bible text key this is a common and usually acceptable trade-off, but it is a real one, and it means anyone can spend your quota. If that matters to you, add a second Lava Application endpoint that fetches from API.Bible server-side and have the template call that instead of calling the API directly. Step 7 — Set security Two separate things need attention, and confusing them is the most common problem when setting this up. On the Lava Application, grant ViewExecute → Allow to whoever should be able to save — for a congregation-wide rollout, All Authenticated Users. On the page, grant View as you would for any other page. Because the endpoint's Security Mode is Application View, the endpoint's own security rows are not consulted at all. The application's permission is the operative one. Test with a normal member account, not an administrator. Rock administrators bypass Lava Application security entirely, so an admin account will always succeed and will never reveal a missing grant. Step 8 — Try it Sign in as an ordinary member and open the page. Build a plan from a template and click Start my plan. Check off a reading on the Today view. A brief "Saved" message should appear. In Rock, open that person's profile and confirm the eight attributes now hold values. Open the reader and confirm passage text loads. If an audio Bible is available on your key, press play and confirm the highlight tracks the narration. If the save fails, the on-screen message includes the HTTP status code, which will point you at the right section of Troubleshooting below. Reporting on the data Because everything lands on the person record, ordinary data views do the work. Note that these values only change when a person saves. Someone who stops using the app leaves their numbers frozen at their last visit. Anchor anything time-sensitive on DailyWordLastRead rather than on the streak or count fields. Some starting points: People who may need encouragement — DailyWordLastRead more than 14 days ago AND DailyWordDaysRead is at least 5. The second clause matters: it limits the list to people who genuinely established a habit and then stopped, rather than everyone who once opened the page. People to celebrate — DailyWordShare is True AND DailyWordDaysReadYear is at least n AND DailyWordDaysReadYearOfis the current year. Active readers — DailyWordLastRead within the last 7 days. Respect the consent flag. DailyWordShare exists precisely so that reading history is not treated as public information by default, and any use beyond internal pastoral care should be gated on it. Customising Colours and type — every visual value is a CSS custom property in the #dw-root block at the top of the stylesheet. Change the palette and font stacks there and the whole application follows. All CSS is scoped under #dw-root so it will not leak into your theme, and your theme cannot leak in. Reading plan templates — the TEMPLATES array in the JavaScript. Each entry is a name, a description, and a list of sections, where a section is a list of book names and a frequency. Presets — the PRESETS array, used by the chips in the plan builder. Estimated reading time — DW_MIN_PER_CHAPTER, minutes per chapter, used for the "about n chapters a day" summary. Troubleshooting The page renders blank, or the output stops mid-way, with no error and a 200 status. Lava treats /- as the opening of a block comment and //- as a line comment, anywhere in the template — including inside CSS and JavaScript, because Lava renders on the server before the browser ever sees the file. An unclosed /- silently swallows everything after it. Search your template for both sequences and expect zero results. Common accidental sources are the phrase "and/or", CSS values such as grid-row: 1/-1, and negative array indices in comments. This is also why all of the CSS and JavaScript in the template sits inside a {% raw %} block, which must itself contain no {{, }}, or {% sequences. Saving returns 401. Rock returns 401 both for "not signed in" and for "signed in but not permitted", so check three things: the person has ViewExecute on the application (not the endpoint); the request carries the X-Helix-CSRF-Protection: trueheader; and the session is genuinely still authenticated. Remember that testing as an administrator will not reproduce a permissions problem. Attribute values do not appear. Confirm the attribute keys match exactly — the endpoint silently skips attributes it cannot find, by design, so a typo produces no error. Confirm the endpoint has the Sql command enabled. Then reload the person's profile; because the endpoint writes attribute values directly, a cached value may briefly persist. Saves fail on iPhones when switching apps. The template sends its save request with keepalive: true, which lets an in-flight request finish after the page is backgrounded. If you modify the save code, keep that flag. Audio does not play. Check the browser console. The application logs the specific reason it skipped a chapter, which is usually either no audio Bible licensed on the key or no audio published for that particular chapter. Chapters without audio are skipped rather than blocking the queue. Follow-along highlighting drifts. Some audio editions publish verse timecodes and some do not. Where they are absent the application estimates them from the audio's duration and the length of each verse, calibrating as it goes. Tapping the verse currently being read resyncs it. Design notes Two decisions in this application are worth explaining, because they are opinions rather than technical necessities and you may reasonably choose otherwise. The headline number is days read, not streak. Streaks are motivating until they break, at which point they become a reason to stop. The number this app puts in front of people — and stores for reporting — is total days in Scripture this calendar year, which never resets and cannot be lost by missing a Tuesday. The streak is still shown, but it is not the measure the year is judged by. Reading history is append-only. Unchecking a passage removes it from that day's progress but never removes the date from the reading history. Rebuilding a plan, or starting a fresh year, does not clear it either. History records that a person opened Scripture on a given day, and no later editing of a plan changes whether that happened. Download File