Merge pull request #2 from nmemmert/feature/online-class-upgrade

Feature/online class upgrade
This commit is contained in:
nmemmert
2026-06-02 14:15:26 -04:00
committed by GitHub
13 changed files with 1956 additions and 205 deletions
+111 -5
View File
@@ -85,7 +85,12 @@
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-11-09T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-05-06T00:00:00Z"
},
{
"id": "1-3-8",
@@ -101,7 +106,9 @@
"What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?"
],
"releasedAt": "2026-11-23T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z"
},
{
"id": "1-9-14",
@@ -117,6 +124,8 @@
"What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z"
},
{
@@ -133,6 +142,8 @@
"What stands out most about Christs supremacy?",
"Why is this passage central to the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z"
},
{
@@ -149,6 +160,8 @@
"How does Paul describe the believers former condition?",
"What does continuing in the faith look like?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z"
},
{
@@ -165,6 +178,8 @@
"What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z"
},
{
@@ -181,6 +196,8 @@
"What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z"
},
{
@@ -197,6 +214,8 @@
"What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z"
},
{
@@ -213,6 +232,8 @@
"Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z"
},
{
@@ -229,6 +250,8 @@
"What changes because of Christs work here?",
"How does victory over the powers encourage believers today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z"
},
{
@@ -245,6 +268,8 @@
"What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z"
},
{
@@ -261,6 +286,8 @@
"How does Paul describe false spirituality?",
"Why is connection to Christ essential?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z"
},
{
@@ -277,6 +304,8 @@
"Why do human regulations fail to transform the heart?",
"What does real spiritual change require?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z"
},
{
@@ -293,6 +322,8 @@
"What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z"
},
{
@@ -309,6 +340,8 @@
"Which old patterns does Paul name?",
"Why is decisive change necessary?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z"
},
{
@@ -325,6 +358,8 @@
"What does renewal in Christ produce?",
"How does this reshape how believers see one another?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z"
},
{
@@ -341,6 +376,8 @@
"Which virtues are most needed in your context?",
"How does love bind these virtues together?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z"
},
{
@@ -357,6 +394,8 @@
"What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z"
},
{
@@ -373,6 +412,8 @@
"How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z"
},
{
@@ -389,6 +430,8 @@
"How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z"
},
{
@@ -405,6 +448,8 @@
"Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z"
},
{
@@ -421,6 +466,8 @@
"How are prayer and witness connected here?",
"What does gracious speech look like in real life?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z"
},
{
@@ -437,6 +484,8 @@
"Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z"
},
{
@@ -453,6 +502,8 @@
"What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z"
},
{
@@ -469,6 +520,8 @@
"What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z"
}
]
@@ -512,7 +565,12 @@
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-11-09T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-05-06T00:00:00Z"
},
{
"id": "1-3-8",
@@ -528,7 +586,9 @@
"What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?"
],
"releasedAt": "2026-11-23T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z"
},
{
"id": "1-9-14",
@@ -544,6 +604,8 @@
"What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z"
},
{
@@ -560,6 +622,8 @@
"What stands out most about Christs supremacy?",
"Why is this passage central to the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z"
},
{
@@ -576,6 +640,8 @@
"How does Paul describe the believers former condition?",
"What does continuing in the faith look like?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z"
},
{
@@ -592,6 +658,8 @@
"What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z"
},
{
@@ -608,6 +676,8 @@
"What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z"
},
{
@@ -624,6 +694,8 @@
"What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z"
},
{
@@ -640,6 +712,8 @@
"Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z"
},
{
@@ -656,6 +730,8 @@
"What changes because of Christs work here?",
"How does victory over the powers encourage believers today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z"
},
{
@@ -672,6 +748,8 @@
"What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z"
},
{
@@ -688,6 +766,8 @@
"How does Paul describe false spirituality?",
"Why is connection to Christ essential?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z"
},
{
@@ -704,6 +784,8 @@
"Why do human regulations fail to transform the heart?",
"What does real spiritual change require?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z"
},
{
@@ -720,6 +802,8 @@
"What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z"
},
{
@@ -736,6 +820,8 @@
"Which old patterns does Paul name?",
"Why is decisive change necessary?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z"
},
{
@@ -752,6 +838,8 @@
"What does renewal in Christ produce?",
"How does this reshape how believers see one another?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z"
},
{
@@ -768,6 +856,8 @@
"Which virtues are most needed in your context?",
"How does love bind these virtues together?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z"
},
{
@@ -784,6 +874,8 @@
"What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z"
},
{
@@ -800,6 +892,8 @@
"How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z"
},
{
@@ -816,6 +910,8 @@
"How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z"
},
{
@@ -832,6 +928,8 @@
"Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z"
},
{
@@ -848,6 +946,8 @@
"How are prayer and witness connected here?",
"What does gracious speech look like in real life?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z"
},
{
@@ -864,6 +964,8 @@
"Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z"
},
{
@@ -880,6 +982,8 @@
"What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z"
},
{
@@ -896,6 +1000,8 @@
"What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z"
}
],
@@ -1069,5 +1175,5 @@
]
}
},
"updatedAt": "2026-06-01T17:25:41.122Z"
"updatedAt": "2026-06-02T17:54:05.948Z"
}
+111 -5
View File
@@ -85,7 +85,12 @@
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-11-09T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-05-06T00:00:00Z"
},
{
"id": "1-3-8",
@@ -101,7 +106,9 @@
"What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?"
],
"releasedAt": "2026-11-23T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z"
},
{
"id": "1-9-14",
@@ -117,6 +124,8 @@
"What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z"
},
{
@@ -133,6 +142,8 @@
"What stands out most about Christs supremacy?",
"Why is this passage central to the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z"
},
{
@@ -149,6 +160,8 @@
"How does Paul describe the believers former condition?",
"What does continuing in the faith look like?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z"
},
{
@@ -165,6 +178,8 @@
"What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z"
},
{
@@ -181,6 +196,8 @@
"What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z"
},
{
@@ -197,6 +214,8 @@
"What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z"
},
{
@@ -213,6 +232,8 @@
"Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z"
},
{
@@ -229,6 +250,8 @@
"What changes because of Christs work here?",
"How does victory over the powers encourage believers today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z"
},
{
@@ -245,6 +268,8 @@
"What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z"
},
{
@@ -261,6 +286,8 @@
"How does Paul describe false spirituality?",
"Why is connection to Christ essential?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z"
},
{
@@ -277,6 +304,8 @@
"Why do human regulations fail to transform the heart?",
"What does real spiritual change require?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z"
},
{
@@ -293,6 +322,8 @@
"What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z"
},
{
@@ -309,6 +340,8 @@
"Which old patterns does Paul name?",
"Why is decisive change necessary?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z"
},
{
@@ -325,6 +358,8 @@
"What does renewal in Christ produce?",
"How does this reshape how believers see one another?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z"
},
{
@@ -341,6 +376,8 @@
"Which virtues are most needed in your context?",
"How does love bind these virtues together?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z"
},
{
@@ -357,6 +394,8 @@
"What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z"
},
{
@@ -373,6 +412,8 @@
"How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z"
},
{
@@ -389,6 +430,8 @@
"How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z"
},
{
@@ -405,6 +448,8 @@
"Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z"
},
{
@@ -421,6 +466,8 @@
"How are prayer and witness connected here?",
"What does gracious speech look like in real life?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z"
},
{
@@ -437,6 +484,8 @@
"Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z"
},
{
@@ -453,6 +502,8 @@
"What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z"
},
{
@@ -469,6 +520,8 @@
"What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z"
}
]
@@ -512,7 +565,12 @@
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-11-09T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-05-06T00:00:00Z"
},
{
"id": "1-3-8",
@@ -528,7 +586,9 @@
"What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?"
],
"releasedAt": "2026-11-23T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z"
},
{
"id": "1-9-14",
@@ -544,6 +604,8 @@
"What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z"
},
{
@@ -560,6 +622,8 @@
"What stands out most about Christs supremacy?",
"Why is this passage central to the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z"
},
{
@@ -576,6 +640,8 @@
"How does Paul describe the believers former condition?",
"What does continuing in the faith look like?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z"
},
{
@@ -592,6 +658,8 @@
"What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z"
},
{
@@ -608,6 +676,8 @@
"What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z"
},
{
@@ -624,6 +694,8 @@
"What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z"
},
{
@@ -640,6 +712,8 @@
"Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z"
},
{
@@ -656,6 +730,8 @@
"What changes because of Christs work here?",
"How does victory over the powers encourage believers today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z"
},
{
@@ -672,6 +748,8 @@
"What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z"
},
{
@@ -688,6 +766,8 @@
"How does Paul describe false spirituality?",
"Why is connection to Christ essential?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z"
},
{
@@ -704,6 +784,8 @@
"Why do human regulations fail to transform the heart?",
"What does real spiritual change require?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z"
},
{
@@ -720,6 +802,8 @@
"What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z"
},
{
@@ -736,6 +820,8 @@
"Which old patterns does Paul name?",
"Why is decisive change necessary?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z"
},
{
@@ -752,6 +838,8 @@
"What does renewal in Christ produce?",
"How does this reshape how believers see one another?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z"
},
{
@@ -768,6 +856,8 @@
"Which virtues are most needed in your context?",
"How does love bind these virtues together?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z"
},
{
@@ -784,6 +874,8 @@
"What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z"
},
{
@@ -800,6 +892,8 @@
"How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z"
},
{
@@ -816,6 +910,8 @@
"How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z"
},
{
@@ -832,6 +928,8 @@
"Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z"
},
{
@@ -848,6 +946,8 @@
"How are prayer and witness connected here?",
"What does gracious speech look like in real life?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z"
},
{
@@ -864,6 +964,8 @@
"Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z"
},
{
@@ -880,6 +982,8 @@
"What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z"
},
{
@@ -896,6 +1000,8 @@
"What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z"
}
],
@@ -1069,5 +1175,5 @@
]
}
},
"updatedAt": "2026-06-01T17:25:41.133Z"
"updatedAt": "2026-06-02T17:54:06.010Z"
}
+1 -1
View File
@@ -98,5 +98,5 @@
"topic": "Faith & Life"
}
],
"updatedAt": "2026-06-01T17:25:41.133Z"
"updatedAt": "2026-06-02T17:54:06.010Z"
}
+50
View File
@@ -0,0 +1,50 @@
{
"posts": [
{
"id": "fd6f2d9c-8abc-43b2-8e93-c58c0cb1ff7b",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "reading",
"createdAt": "2026-06-02T17:41:48.684Z",
"replies": []
},
{
"id": "001f2545-e1ee-4748-902b-5da5c47554bb",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "sool",
"createdAt": "2026-06-02T17:41:41.608Z",
"replies": []
},
{
"id": "c40e4c10-47a3-42e8-88bd-78116af5babe",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:16.795Z",
"replies": [
{
"id": "efe0bf3d-66e8-4dad-98c1-2e8c742e3120",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:25.003Z"
},
{
"id": "1d6c3bd1-5ada-4f98-8ca3-5ed15bd00a68",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:34.406Z"
}
]
}
],
"updatedAt": "2026-06-02T17:41:48.685Z"
}
@@ -0,0 +1,3 @@
{
"colossians--1-1-2": "test....teset"
}
@@ -0,0 +1,16 @@
{
"byStudy": {
"colossians": {
"completedSectionIds": [
"1-1-2"
],
"quizAnswers": {
"1-1-2": [
"test",
"test"
]
}
}
},
"updatedAt": "2026-06-02T17:53:29.839Z"
}
+23 -11
View File
@@ -6,19 +6,31 @@
"passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9",
"displayName": "Student Test",
"subscribeNewsletter": false,
"pendingEmailChange": {
"newEmail": "student.test+new@versebyverse.local",
"tokenHash": "ce4671e74a12302744878f690af5bdfbb087c83bebdb9183c1212eacaef25554",
"expiresAt": 1779464255007,
"requestedAt": "2026-05-21T15:37:35.007Z"
},
"avatarUrl": "",
"lastLoginAt": "2026-05-21T15:41:16.259Z"
},
{
"id": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"username": "test.user@versebyverse.local",
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
"displayName": "Test Student",
"subscribeNewsletter": false,
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E",
"lastLoginAt": "2026-06-02T17:56:40.769Z",
"updatedAt": "2026-06-02T17:57:04.582Z",
"enrolledStudySlugs": [
"colossians"
],
"createdAt": "2026-05-21T13:05:00.000Z",
"updatedAt": "2026-05-21T18:41:10.857Z",
"lastLoginAt": "2026-05-21T15:41:16.259Z"
]
},
{
"id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a",
"username": "test.avatar@versebyverse.local",
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
"displayName": "Avatar Test",
"subscribeNewsletter": false,
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"88\" height=\"88\"%3E%3Crect width=\"100%\" height=\"100%\" rx=\"18\" ry=\"18\" fill=\"%2311100d\"/%3E%3Ctext x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-size=\"56\"%3E🐱%3C/text%3E%3C/svg%3E",
"lastLoginAt": "2026-06-02T17:33:13.669Z"
}
],
"updatedAt": "2026-05-21T18:41:10.858Z"
"updatedAt": "2026-06-02T17:57:04.583Z"
}
+532 -50
View File
@@ -61,6 +61,7 @@ const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration
const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes')
const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress')
const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json')
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
@@ -634,6 +635,10 @@ let studyUsers = []
let studyUsersWritePromise = Promise.resolve()
const studyNotesCache = new Map() // userId -> { [sectionId]: string }
const studyNotesWriteQueues = new Map() // userId -> Promise
let studyReminders = { users: {}, updatedAt: new Date().toISOString() }
let studyRemindersWritePromise = Promise.resolve()
const studyProgressCache = new Map() // userId -> { byStudy: Record<string, { completedSectionIds: string[] }> }
const studyProgressWriteQueues = new Map() // userId -> Promise
let studyCommunityPosts = []
let studyCommunityWritePromise = Promise.resolve()
let downloadCounts = {}
@@ -2241,6 +2246,28 @@ function normalizeStudyUsername(value) {
return value.trim().toLowerCase()
}
function getStudyAvatarUrl(subject) {
let customAvatar = ''
let username = ''
if (subject && typeof subject === 'object') {
customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : ''
username = normalizeStudyUsername(subject.username)
} else if (typeof subject === 'string') {
username = normalizeStudyUsername(subject)
}
if (customAvatar) return customAvatar
if (!username) return ''
const hash = createHash('md5').update(username).digest('hex')
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96`
}
function findStudyUserById(userId) {
if (typeof userId !== 'string' || !userId.trim()) return undefined
return studyUsers.find(user => user.id === userId)
}
function normalizeStudySlug(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim().toLowerCase()
@@ -2434,6 +2461,7 @@ function sanitizeStudyUsers(value) {
: []
const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
const subscribeNewsletter = item?.subscribeNewsletter !== false
const studyRemindersEnabled = item?.studyRemindersEnabled === true
const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange)
? {
newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail))
@@ -2451,13 +2479,11 @@ function sanitizeStudyUsers(value) {
passwordHash,
displayName,
subscribeNewsletter,
pendingEmailChange: pendingEmailChange?.newEmail && pendingEmailChange?.tokenHash && pendingEmailChange.expiresAt > Date.now()
? pendingEmailChange
: null,
studyRemindersEnabled,
enrolledStudySlugs,
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(),
avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
pendingEmailChange,
})
}
@@ -2581,6 +2607,195 @@ function queueUserNotesWrite(userId) {
studyNotesWriteQueues.set(userId, next)
}
function getUserProgressFilePath(userId) {
return path.join(STUDY_PROGRESS_DIR, `${userId}.json`)
}
function sanitizeStudyProgress(value) {
const defaultResult = { byStudy: {}, updatedAt: new Date().toISOString() }
if (!value || typeof value !== 'object') return defaultResult
const progress = { byStudy: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() }
if (value.byStudy && typeof value.byStudy === 'object') {
for (const [studySlug, studyData] of Object.entries(value.byStudy)) {
if (typeof studySlug !== 'string' || !studySlug.trim()) continue
const completedSectionIds = Array.isArray(studyData?.completedSectionIds)
? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
: []
const quizAnswers = studyData?.quizAnswers && typeof studyData?.quizAnswers === 'object' && !Array.isArray(studyData.quizAnswers)
? Object.fromEntries(
Object.entries(studyData.quizAnswers)
.filter(([sectionId]) => typeof sectionId === 'string' && sectionId.trim())
.map(([sectionId, answers]) => [
sectionId.trim(),
Array.isArray(answers)
? answers.filter(answer => typeof answer === 'string').map(answer => answer.trim())
: [],
])
)
: {}
progress.byStudy[studySlug.trim().toLowerCase()] = {
completedSectionIds: Array.from(new Set(completedSectionIds)),
quizAnswers,
}
}
}
return progress
}
async function loadUserProgress(userId) {
if (studyProgressCache.has(userId)) return studyProgressCache.get(userId)
try {
const raw = await readFile(getUserProgressFilePath(userId), 'utf8')
const progress = sanitizeStudyProgress(JSON.parse(raw))
studyProgressCache.set(userId, progress)
return progress
} catch {
const progress = { byStudy: {}, updatedAt: new Date().toISOString() }
studyProgressCache.set(userId, progress)
return progress
}
}
function queueUserProgressWrite(userId) {
const prev = studyProgressWriteQueues.get(userId) ?? Promise.resolve()
const next = prev
.then(async () => {
const progress = studyProgressCache.get(userId) ?? { byStudy: {}, updatedAt: new Date().toISOString() }
await mkdir(STUDY_PROGRESS_DIR, { recursive: true })
await writeFile(getUserProgressFilePath(userId), JSON.stringify(progress, null, 2), 'utf8')
})
.catch(err => {
console.error(`[study-progress] failed to write progress for user ${userId}:`, err)
})
studyProgressWriteQueues.set(userId, next)
}
function sanitizeStudyReminders(value) {
const defaultResult = { users: {}, updatedAt: new Date().toISOString() }
if (!value || typeof value !== 'object' || Array.isArray(value)) return defaultResult
const reminders = { users: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() }
if (value.users && typeof value.users === 'object') {
for (const [userId, userData] of Object.entries(value.users)) {
if (typeof userId !== 'string' || !userId.trim()) continue
const studies = typeof userData === 'object' && userData && !Array.isArray(userData)
? userData
: {}
const normalizedStudies = {}
for (const [studySlug, sectionIds] of Object.entries(studies)) {
if (typeof studySlug !== 'string' || !studySlug.trim()) continue
const ids = Array.isArray(sectionIds)
? sectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
: []
if (ids.length > 0) normalizedStudies[studySlug.trim().toLowerCase()] = Array.from(new Set(ids))
}
reminders.users[userId.trim()] = normalizedStudies
}
}
return reminders
}
async function loadStudyRemindersFromDisk() {
try {
const raw = await readFile(STUDY_REMINDERS_FILE, 'utf8')
studyReminders = sanitizeStudyReminders(JSON.parse(raw))
} catch {
studyReminders = { users: {}, updatedAt: new Date().toISOString() }
}
}
function queueStudyRemindersWrite() {
studyRemindersWritePromise = studyRemindersWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(STUDY_REMINDERS_FILE, JSON.stringify(studyReminders, null, 2), 'utf8')
})
.catch(err => {
console.error('[study-reminders] failed to write reminders:', err)
})
}
async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">A new study lesson is available in ${escapeHtml(studyTitle)}.</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) is now unlocked.</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
to: [email],
subject: process.env.RESEND_REMINDER_SUBJECT ?? `New lesson available: ${sectionTitle}`,
text:
`Hi ${namePart},\n\n` +
`A new lesson is available in ${studyTitle}.\n` +
`${sectionTitle} (${sectionReference}) is now unlocked.\n\n` +
`Open it here: ${sectionUrl}\n\n` +
`Grace and peace,\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'New Lesson Available',
eyebrow: 'Study Reminder',
bodyHtml,
ctaLabel: 'Open the Lesson',
ctaUrl: sectionUrl,
footerHtml,
}),
})
if (error) console.error('[study-reminder] send error:', error)
} catch (err) {
console.error('[study-reminder] send exception:', err)
}
}
async function scheduleStudyReminders() {
if (!cachedSiteContent) return
const now = new Date()
const userIds = studyUsers.filter(user => user.studyRemindersEnabled === true).map(user => user.id)
if (userIds.length === 0) return
for (const user of studyUsers) {
if (user.studyRemindersEnabled !== true) continue
const email = user.username
const displayName = user.displayName || email
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (enrolledStudySlugs.length === 0) continue
const userSent = studyReminders.users[user.id] ?? {}
for (const studySlug of enrolledStudySlugs) {
const study = Array.isArray(cachedSiteContent.studies)
? cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined
if (!study) continue
for (const section of study.sections ?? []) {
const sectionId = section.id
const releaseDate = getSectionReleaseDate(section)
if (!releaseDate) continue
if (releaseDate > now) continue
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
if (sentForStudy.includes(sectionId)) continue
const hoursSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60)
if (hoursSinceRelease > 24) continue
const sectionUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/${study.slug}/${section.id}`)
await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl)
userSent[studySlug] = [...sentForStudy, sectionId]
studyReminders.users[user.id] = userSent
}
}
}
studyReminders.updatedAt = new Date().toISOString()
queueStudyRemindersWrite()
}
async function migrateStudyNotesIfNeeded() {
try {
const raw = await readFile(STUDY_NOTES_FILE, 'utf8')
@@ -2724,6 +2939,8 @@ app.get('/api/study-auth/status', (req, res) => {
username: user?.username ?? '',
displayName: user?.displayName ?? '',
subscribeNewsletter: user?.subscribeNewsletter !== false,
studyRemindersEnabled: user?.studyRemindersEnabled === true,
avatarUrl: user ? getStudyAvatarUrl(user) : '',
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
})
})
@@ -2756,6 +2973,7 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
passwordHash: hashStudyPassword(password),
displayName,
subscribeNewsletter: subscribe,
studyRemindersEnabled: false,
pendingEmailChange: null,
enrolledStudySlugs: [],
createdAt: now,
@@ -2783,6 +3001,8 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
username: user.username,
displayName: user.displayName,
subscribeNewsletter: user.subscribeNewsletter,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs,
})
})
@@ -2817,6 +3037,8 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
username: user.username,
displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
})
})
@@ -2941,6 +3163,126 @@ app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
res.json({ ok: true, note })
})
app.get('/api/study-progress/:studySlug', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) {
res.status(400).json({ message: 'Study slug is required.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
return
}
const progress = await loadUserProgress(user.id)
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
res.json({ studySlug, completedSectionIds })
})
app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return
}
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
if (!studyProgress.completedSectionIds.includes(sectionId)) {
studyProgress.completedSectionIds = [...studyProgress.completedSectionIds, sectionId]
}
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
})
app.get('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
return
}
const progress = await loadUserProgress(user.id)
const quizAnswers = progress.byStudy[studySlug]?.quizAnswers?.[sectionId] ?? []
res.json({ studySlug, sectionId, answers: quizAnswers })
})
app.post('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
return
}
const rawAnswers = req.body?.answers
const answers = Array.isArray(rawAnswers)
? rawAnswers.map(answer => typeof answer === 'string' ? answer.trim() : '').filter(Boolean)
: []
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
studyProgress.quizAnswers = studyProgress.quizAnswers || {}
studyProgress.quizAnswers[sectionId] = answers
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, sectionId, answers })
})
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return
}
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
studyProgress.completedSectionIds = studyProgress.completedSectionIds.filter(id => id !== sectionId)
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
})
app.get('/api/study-community', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.studySlug : '')
@@ -2959,6 +3301,23 @@ app.get('/api/study-community', requireStudyAuth, async (req, res) => {
.filter(post => post.studySlug === studySlug)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 50)
.map(post => {
const author = findStudyUserById(post.authorUserId)
const enrichedPost = {
...post,
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
replies: Array.isArray(post.replies)
? post.replies.map(reply => {
const replyAuthor = findStudyUserById(reply.authorUserId)
return {
...reply,
authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || ''),
}
})
: [],
}
return enrichedPost
})
res.json({
studySlug,
@@ -2990,6 +3349,7 @@ app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
sectionId,
authorUserId: user.id,
authorName,
authorAvatarUrl: getStudyAvatarUrl(user.username),
message,
createdAt: now,
replies: [],
@@ -3027,6 +3387,7 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (
id: randomUUID(),
authorUserId: user.id,
authorName: user.displayName?.trim() || user.username,
authorAvatarUrl: getStudyAvatarUrl(user.username),
message,
createdAt: new Date().toISOString(),
}
@@ -3042,86 +3403,151 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (
app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
const progress = await loadUserProgress(user.id)
// Build a lookup of sectionId -> { title, reference, studyTitle } from cached content
// Build a lookup of sectionId -> { title, reference, studyTitle, description } from cached content
const sectionMeta = {}
const content = cachedSiteContent
if (content) {
const studies = Array.isArray(content.studies) && content.studies.length > 0
? content.studies
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', sections: content.colossiansStudySections ?? [] }]
for (const study of studies) {
for (const section of (study.sections ?? [])) {
sectionMeta[`${study.slug}--${section.id}`] = {
studyTitle: study.title,
title: section.title,
reference: section.reference,
}
const studies = content && Array.isArray(content.studies) && content.studies.length > 0
? content.studies
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', description: '', sections: content?.colossiansStudySections ?? [] }]
for (const study of studies) {
for (const section of (study.sections ?? [])) {
sectionMeta[`${study.slug}--${section.id}`] = {
studyTitle: study.title,
studyDescription: typeof study.description === 'string' ? study.description : '',
title: section.title,
reference: section.reference,
studyQuestions: Array.isArray(section.studyQuestions) ? section.studyQuestions : [],
}
}
}
// Group notes by study
const byStudy = {}
const studyEntries = {}
for (const [noteKey, noteText] of Object.entries(notes)) {
if (!noteText?.trim()) continue
const dashIndex = noteKey.indexOf('--')
const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown'
if (!byStudy[studySlug]) byStudy[studySlug] = []
byStudy[studySlug].push({ noteKey, noteText })
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].noteText = noteText.trim()
}
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
const quizAnswersBySection = studyProgress.quizAnswers || {}
for (const [sectionId, answers] of Object.entries(quizAnswersBySection)) {
if (!Array.isArray(answers) || answers.length === 0) continue
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].quizAnswers = answers.filter(answer => typeof answer === 'string' && answer.trim()).map(answer => answer.trim())
}
}
const studySlugs = Array.from(new Set([
...Object.keys(studyEntries),
...Object.values(studies).map(study => study.slug),
]))
const docChildren = [
new Paragraph({
text: 'My Study Notes',
heading: HeadingLevel.TITLE,
}),
new Paragraph({
children: [new TextRun({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true })],
spacing: { after: 400 },
}),
new Paragraph({ text: 'Verse by Verse with Nate', heading: HeadingLevel.TITLE }),
new Paragraph({ text: 'My Study Export', heading: HeadingLevel.HEADING_1, spacing: { after: 240 } }),
new Paragraph({ text: `Student: ${user.displayName || user.username}`, spacing: { after: 120 } }),
new Paragraph({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true, spacing: { after: 400 } }),
]
for (const [studySlug, entries] of Object.entries(byStudy)) {
const studyTitle = entries[0] ? (sectionMeta[entries[0].noteKey]?.studyTitle ?? studySlug) : studySlug
for (const studySlug of studySlugs) {
const study = studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
const studyTitle = study?.title || studySlug
const studyDescription = typeof study?.description === 'string' ? study.description : ''
const sectionIds = studyEntries[studySlug] ? Object.keys(studyEntries[studySlug]) : []
if (sectionIds.length === 0) continue
docChildren.push(
new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }),
)
for (const { noteKey, noteText } of entries) {
const meta = sectionMeta[noteKey]
const lessonTitle = meta?.title ?? noteKey
const reference = meta?.reference ?? ''
if (studyDescription) {
docChildren.push(
new Paragraph({ text: lessonTitle, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }),
new Paragraph({ text: studyDescription, spacing: { after: 240 } }),
)
if (reference) {
}
const noteCount = sectionIds.filter(sectionId => studyEntries[studySlug][sectionId].noteText).length
const quizCount = sectionIds.filter(sectionId => Array.isArray(studyEntries[studySlug][sectionId].quizAnswers) && studyEntries[studySlug][sectionId].quizAnswers.length > 0).length
docChildren.push(
new Paragraph({ text: `Notes: ${noteCount} | Quiz sections: ${quizCount}`, italics: true, spacing: { after: 240 } }),
)
const orderedSectionIds = study?.sections?.map(section => section.id).filter(id => sectionIds.includes(id)) ?? sectionIds
for (const sectionId of orderedSectionIds) {
const entry = studyEntries[studySlug][sectionId]
if (!entry) continue
const meta = sectionMeta[`${studySlug}--${sectionId}`] || { title: sectionId, reference: '' }
docChildren.push(
new Paragraph({ text: meta.title, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }),
)
if (meta.reference) {
docChildren.push(
new Paragraph({
children: [new TextRun({ text: reference, italics: true, color: '555555' })],
spacing: { after: 120 },
}),
new Paragraph({ children: [new TextRun({ text: meta.reference, italics: true, color: '555555' })], spacing: { after: 120 } }),
)
}
for (const line of noteText.split('\n')) {
if (entry.noteText) {
docChildren.push(
new Paragraph({ text: line.trim(), spacing: { after: 80 } }),
new Paragraph({ text: 'Notes', heading: HeadingLevel.HEADING_3, spacing: { before: 120 } }),
)
for (const line of entry.noteText.split('\n')) {
docChildren.push(new Paragraph({ text: line.trim(), spacing: { after: 80 } }))
}
}
if (Array.isArray(meta.studyQuestions) && meta.studyQuestions.length > 0) {
docChildren.push(
new Paragraph({ text: 'Quiz Questions', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }),
)
meta.studyQuestions.forEach((question, index) => {
docChildren.push(
new Paragraph({
children: [
new TextRun({ text: `${index + 1}. `, bold: true }),
new TextRun({ text: question }),
],
spacing: { after: 80 },
}),
)
})
}
if (Array.isArray(entry.quizAnswers) && entry.quizAnswers.length > 0) {
docChildren.push(
new Paragraph({ text: 'Quiz Answers', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }),
)
entry.quizAnswers.forEach((answer, index) => {
docChildren.push(
new Paragraph({
children: [
new TextRun({ text: `Answer ${index + 1}: `, bold: true }),
new TextRun({ text: answer }),
],
spacing: { after: 80 },
}),
)
})
}
}
}
if (docChildren.length <= 2) {
docChildren.push(new Paragraph({ text: 'No notes saved yet.', spacing: { before: 200 } }))
if (docChildren.length <= 4) {
docChildren.push(new Paragraph({ text: 'No notes or quiz answers saved yet.', spacing: { before: 200 } }))
}
const doc = new Document({
creator: 'Verse by Verse with Nate',
title: 'My Study Notes',
title: 'My Study Export',
sections: [{ children: docChildren }],
})
const buffer = await Packer.toBuffer(doc)
const filename = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx`
const filename = `my-study-export-${new Date().toISOString().slice(0, 10)}.docx`
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
res.send(buffer)
@@ -3154,6 +3580,7 @@ app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStud
app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
const progress = await loadUserProgress(user.id)
const noteEntries = Object.entries(notes)
const studies = getStudyCatalog().map(study => {
@@ -3161,13 +3588,14 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
? (cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === study.slug)?.sections?.length ?? 0)
: 0
const noteCount = noteEntries.filter(([key, value]) => key.startsWith(`${study.slug}--`) && typeof value === 'string' && value.trim()).length
const completedLessons = progress.byStudy[study.slug]?.completedSectionIds?.length ?? 0
return {
slug: study.slug,
title: study.title,
status: study.status,
enrolled: isStudyUserEnrolled(user, study.slug),
totalLessons,
completedLessons: noteCount,
completedLessons,
noteCount,
}
})
@@ -3177,6 +3605,8 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
username: user.username,
displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user),
},
stats: {
noteCount: Object.keys(notes).length,
@@ -3190,16 +3620,59 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
app.post('/api/study-account/profile', requireStudyAuth, (req, res) => {
const user = req.studyUser
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
const avatarUrl = typeof req.body?.avatarUrl === 'string' ? req.body.avatarUrl.trim() : ''
if (avatarUrl && !/^https?:\/\//i.test(avatarUrl) && !avatarUrl.startsWith('/uploads/') && !avatarUrl.startsWith('data:image/')) {
res.status(400).json({ message: 'Avatar must be a valid uploaded image, data URI, or https URL.' })
return
}
user.displayName = displayName
user.avatarUrl = avatarUrl
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName })
res.json({ ok: true, displayName: user.displayName, avatarUrl: user.avatarUrl || getStudyAvatarUrl(user) })
})
app.post('/api/study-account/avatar-upload', requireStudyAuth, async (req, res) => {
try {
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const ext = inferImageExtensionFromDataUrl(dataUrl)
if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' })
return
}
const base64 = dataUrl.split(',')[1] ?? ''
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > (4 * 1024 * 1024)) {
res.status(400).json({ message: 'Upload must be between 1 byte and 4MB.' })
return
}
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
const finalName = `${baseName || 'avatar'}-${Date.now()}${ext}`
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
const metadata = await readUploadsMetadata()
metadata[finalName] = []
await writeUploadsMetadata(metadata)
res.json({ ok: true, url: `/uploads/${finalName}` })
} catch (err) {
console.error('[study-account-avatar-upload] upload error:', err)
res.status(500).json({ message: 'Avatar upload failed.' })
}
})
app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
const user = req.studyUser
const subscribeNewsletter = req.body?.subscribeNewsletter === true
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
user.subscribeNewsletter = subscribeNewsletter
user.studyRemindersEnabled = studyRemindersEnabled
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
@@ -3207,7 +3680,7 @@ app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
}
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter })
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true })
})
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
@@ -5023,6 +5496,7 @@ Promise.all([
loadDraftQuestionsFromDisk(),
loadStudyUsersFromDisk(),
loadStudyCommunityFromDisk(),
loadStudyRemindersFromDisk(),
migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(),
loadPodcastChecklistFromDisk(),
@@ -5045,6 +5519,14 @@ Promise.all([
}
}, 60 * 60 * 1000)
// Send study reminder emails for newly released lessons every hour
setInterval(() => {
scheduleStudyReminders().catch(err => {
console.error('[study-reminders] failed to schedule reminders:', err)
})
}, 60 * 60 * 1000)
void scheduleStudyReminders()
app.listen(PORT, () => {
logResendEmailAlignmentWarnings()
console.log(`Portfolio app listening on http://localhost:${PORT}`)
+5 -1
View File
@@ -257,10 +257,14 @@ function sanitizeColossiansStudySections(value) {
studyQuestions: Array.isArray(item.studyQuestions)
? item.studyQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20)
: [],
checkpointPrompt: typeof item.checkpointPrompt === 'string' ? item.checkpointPrompt.trim().slice(0, 600) : '',
checkpointQuestions: Array.isArray(item.checkpointQuestions)
? item.checkpointQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20)
: [],
...(releasedAt ? { releasedAt } : {}),
}
})
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText)
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0)
}
function sanitizeStudies(value) {
+15
View File
@@ -79,6 +79,10 @@ function SortableLessonSection({ section, study, updateStudySection, removeStudy
<label htmlFor={`study-section-summary-${study.id}-${section.id}`}>Short Summary</label>
<textarea id={`study-section-summary-${study.id}-${section.id}`} rows={3} value={section.summary} placeholder="One to two sentences that summarize the section." onChange={e => updateStudySection(study.id, section.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-announcement-${study.id}-${section.id}`}>Lesson Announcement</label>
<textarea id={`study-section-announcement-${study.id}-${section.id}`} rows={3} value={section.announcement ?? ''} placeholder="A short instructor announcement for this lesson." onChange={e => updateStudySection(study.id, section.id, 'announcement', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
@@ -99,6 +103,15 @@ function SortableLessonSection({ section, study, updateStudySection, removeStudy
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-checkpoint-prompt-${study.id}-${section.id}`}>Checkpoint Prompt</label>
<input id={`study-section-checkpoint-prompt-${study.id}-${section.id}`} type="text" value={section.checkpointPrompt ?? ''} placeholder="A short prompt for the checkpoint" onChange={e => updateStudySection(study.id, section.id, 'checkpointPrompt', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-checkpoint-questions-${study.id}-${section.id}`}>Checkpoint Questions</label>
<textarea id={`study-section-checkpoint-questions-${study.id}-${section.id}`} rows={5} value={(section.checkpointQuestions ?? []).join('\n')} placeholder="One checkpoint question per line" onChange={e => updateStudySection(study.id, section.id, 'checkpointQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
<p className="admin-field-help">Optional reflection questions that learners answer before completing the lesson.</p>
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeStudySection(study.id, section.id)}>Remove Lesson</button>
</div>
@@ -2064,6 +2077,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
commentary: '',
greekNotes: [],
studyQuestions: [],
checkpointPrompt: '',
checkpointQuestions: [],
},
],
}
+2 -1
View File
@@ -4,7 +4,7 @@ import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } fro
import AdminPage from './AdminPage'
import QASection from './components/QASection'
import ContactForm from './components/ContactForm'
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage } from './colossiansStudy'
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
import { SpotifyIcon } from './icons'
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
import { DEFAULTS } from './content'
@@ -2053,6 +2053,7 @@ export default function App() {
<Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} />
<Route path="/study/:studySlug/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage content={content} />} />} />
<Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} />
<Route path="/study/:studySlug/:sectionId/quiz" element={<StudyRouteFrame content={content} child={<StudyQuizPage content={content} />} />} />
<Route path="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} />
<Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
+1064 -130
View File
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -10,6 +10,9 @@ export type ColossiansStudySection = {
greekNotes: string[]
studyQuestions: string[]
focusQuestion?: string
announcement?: string
checkpointPrompt?: string
checkpointQuestions?: string[]
releasedAt?: string
}
@@ -25,8 +28,25 @@ function section(
studyQuestions: string[],
greekNotes: string[] = [],
releasedAt: string | undefined = undefined,
checkpointPrompt: string | undefined = undefined,
checkpointQuestions: string[] = [],
): ColossiansStudySection {
return { id, chapter, reference, title, audioEmbedUrl, passageText, summary, commentary, studyQuestions, greekNotes, ...(releasedAt ? { releasedAt } : {}) }
return {
id,
chapter,
reference,
title,
audioEmbedUrl,
passageText,
summary,
commentary,
studyQuestions,
greekNotes,
announcement: undefined,
checkpointPrompt,
checkpointQuestions,
...(releasedAt ? { releasedAt } : {}),
}
}
export const DEFAULT_COLOSSIANS_STUDY_SECTIONS: ColossiansStudySection[] = [
@@ -50,6 +70,8 @@ export const DEFAULT_COLOSSIANS_STUDY_SECTIONS: ColossiansStudySection[] = [
'Peace (eirene): wholeness and well-being that flows from reconciliation with God.',
],
'2026-11-09T00:00:00Z',
'What do you want to remember from this greeting as you begin the lesson?',
['Why does Paul begin with grace and peace?', 'What tone does this create for the rest of the study?'],
),
section('1-3-8', 1, '1:3-8', 'Thanksgiving and Report', '', '', 'Paul thanks God for faith, hope, and love, and reports that the gospel is bearing fruit.', 'The gospel is shown to be active and alive. Paul points to evidence of real spiritual growth, not merely religious activity.', ['What signs of gospel growth does Paul mention?', 'How do faith, hope, and love work together here?'], [], '2026-11-23T00:00:00Z'),
section('1-9-14', 1, '1:9-14', 'Paul\'s Prayer', '', '', 'Paul prays for spiritual wisdom, endurance, and a life worthy of the Lord.', 'This prayer shows that knowledge and fruitfulness belong together. Paul wants the believers to know God\'s will and walk in a way that reflects it.', ['What does Paul ask God to produce?', 'How does this prayer shape the goals of the study?'], [], '2026-12-07T00:00:00Z'),