some backend fixes and add android app

This commit is contained in:
timka123 committed 2026-10-19 14:52:20 +03:00
1 parent 772fdbfded
commit 1e55144291
69 files changed
+7111 -7

No files matched your search

@@ -0,0 +1,256 @@
package tech.kadence.android
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import org.junit.Test
import tech.kadence.android.data.dto.AuthMeDto
import tech.kadence.android.data.dto.BlockEventsDto
import tech.kadence.android.data.dto.BlockerPolicyDto
import tech.kadence.android.data.dto.DailyReportEnvelopeDto
import tech.kadence.android.data.dto.StatsSummaryDto
import tech.kadence.android.data.dto.TaskListDto
import tech.kadence.android.data.dto.TimerStateDto
import tech.kadence.android.data.net.KadenceJson
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The contract test, against committed mock data.
*
* The backend declares no response models, so there is nothing to generate DTOs from
* and nothing to validate them against — the only defence is fixtures plus deliberate
* corruption. Each DTO is decoded from a realistic fixture, then from the same fixture
* with every optional key stripped, with unknown keys added, and with nulls where a
* value is declared. None of that may throw, because the alternative is a phone that
* crashes when the server grows a field.
*
* These tests never touch a network or a running backend.
*/
class DtoToleranceTest {
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) {
"missing fixture: $name"
}.bufferedReader().readText()
private inline fun <reified T> decode(name: String): T =
KadenceJson.decodeFromString<T>(fixture(name))
/** The same payload with three keys the client has never heard of. */
private fun withUnknownKeys(raw: String): String {
val obj = KadenceJson.parseToJsonElement(raw).jsonObject
val grown = buildJsonObject {
obj.forEach { (k, v) -> put(k, v) }
put("some_new_field", JsonPrimitive("surprise"))
put("another_one", JsonPrimitive(42))
put("nested_novelty", buildJsonObject { put("x", JsonPrimitive(1)) })
}
return grown.toString()
}
/** Every top-level value replaced with null, as a sloppy serialiser might. */
private fun allNulls(raw: String): String {
val obj = KadenceJson.parseToJsonElement(raw).jsonObject
val nulled = buildJsonObject {
obj.keys.forEach { k -> put(k, kotlinx.serialization.json.JsonNull) }
}
return nulled.toString()
}
// --- the happy path, asserting the values that matter ---------------------
@Test
fun `a running interval decodes`() {
val state = decode<TimerStateDto>("timer_state_running.json")
val current = assertNotNull(state.current)
assertEquals(412L, current.id)
assertEquals("work", current.kind)
assertEquals(1104, current.remainingSeconds)
assertEquals(1500, current.plannedSeconds)
assertEquals(1, current.interruptions)
assertEquals("PLAT-184", current.task?.externalId)
assertEquals(6, state.completedToday)
assertEquals("short_break", state.nextKind)
assertTrue(state.timerEnabled)
}
@Test
fun `durations are minutes, not seconds`() {
// The trap: `pomodoro.state()` sends minutes. Treating them as seconds would
// make every interval 25 seconds long.
val state = decode<TimerStateDto>("timer_state_running.json")
assertEquals(25, state.durations["work"])
assertEquals(5, state.durations["short_break"])
assertEquals(15, state.durations["long_break"])
}
@Test
fun `an idle state has no current interval`() {
val state = decode<TimerStateDto>("timer_state_idle.json")
assertNull(state.current)
assertEquals("work", state.nextKind)
assertEquals(45, state.durations["work"], "the Classic preset")
}
@Test
fun `a complete reply carries finished, auto_started and the celebration`() {
val state = decode<TimerStateDto>("timer_complete_reply.json")
assertEquals(412L, state.finished?.id)
assertEquals("completed", state.finished?.status)
assertEquals(413L, state.autoStarted?.id)
assertEquals("short_break", state.autoStarted?.kind)
assertNotNull(state.celebration)
assertEquals(413L, state.current?.id, "current is the follow-on interval")
}
@Test
fun `an inactive policy hides its lists but keeps all_domains`() {
// The single most important API detail for the focus guard: `domains` and `apps`
// are empty whenever `active` is false, so a client that reads them would think
// the block list was empty and enforce nothing.
val policy = decode<BlockerPolicyDto>("blocker_policy_inactive.json")
assertTrue(!policy.active)
assertTrue(policy.domains.isEmpty())
assertTrue(policy.apps.isEmpty())
assertEquals(3, policy.allDomains.size)
assertEquals(2, policy.allApps.size)
assertNull(policy.sessionId)
}
@Test
fun `an active policy populates both`() {
val policy = decode<BlockerPolicyDto>("blocker_policy_active.json")
assertTrue(policy.active)
assertEquals(policy.allDomains, policy.domains)
assertEquals(listOf("*gram*"), policy.unexpandable)
assertEquals(412L, policy.sessionId)
}
@Test
fun `tasks decode with their buckets and sources`() {
val list = decode<TaskListDto>("tasks.json")
assertEquals(3, list.tasks.size)
assertEquals("overdue", list.tasks[0].bucket)
assertEquals(3, list.tasks[0].daysOverdue)
assertTrue(list.tasks[0].pinned)
assertEquals("local", list.tasks[2].source)
assertEquals(1, list.counts["overdue"])
}
@Test
fun `the daily report decodes whole`() {
val envelope = decode<DailyReportEnvelopeDto>("daily_report.json")
val report = envelope.report
assertEquals(6, report.metrics.pomodoros)
assertEquals(155, report.metrics.focusMinutes)
assertEquals(80, report.metrics.longestStretchMinutes)
assertEquals(2, report.achievements.size)
assertEquals("deep_work", report.achievements[0].code)
assertEquals(2, report.funFacts.size)
assertEquals(72.4, report.score)
assertEquals(4, report.streak.current)
assertEquals(16, report.metrics.blockedAttempts)
assertNull(envelope.sentAt)
}
@Test
fun `stats summary decodes its ranges`() {
val stats = decode<StatsSummaryDto>("stats_summary.json")
assertEquals(3, stats.range.size)
assertEquals(2, stats.heatmap.size)
assertEquals(162.5, stats.lifetime.focusHours)
assertEquals(5.4, stats.average7d.pomodoros)
}
@Test
fun `block events decode both kinds`() {
val events = decode<BlockEventsDto>("block_events.json")
assertEquals(16, events.total)
assertEquals("domain", events.events[0].kind)
assertEquals("killed", events.events[1].action)
assertEquals("youtube.com", events.byTarget[0].target)
}
@Test
fun `auth me decodes`() {
val me = decode<AuthMeDto>("auth_me.json")
assertTrue(me.authenticated)
assertTrue(me.loginRequired)
assertEquals("timka", me.username)
}
// --- the corruption passes ------------------------------------------------
@Test
fun `an empty object decodes into every DTO`() {
// A stripped-down or errored response must still produce usable defaults.
assertNull(KadenceJson.decodeFromString<TimerStateDto>("{}").current)
assertEquals(8, KadenceJson.decodeFromString<TimerStateDto>("{}").goal)
assertTrue(KadenceJson.decodeFromString<TaskListDto>("{}").tasks.isEmpty())
assertTrue(!KadenceJson.decodeFromString<BlockerPolicyDto>("{}").active)
assertEquals(0.0, KadenceJson.decodeFromString<DailyReportEnvelopeDto>("{}").report.score)
assertEquals(0, KadenceJson.decodeFromString<StatsSummaryDto>("{}").range.size)
assertEquals(0, KadenceJson.decodeFromString<BlockEventsDto>("{}").total)
assertTrue(!KadenceJson.decodeFromString<AuthMeDto>("{}").authenticated)
}
@Test
fun `unknown keys are ignored everywhere`() {
val grown = withUnknownKeys(fixture("timer_state_running.json"))
assertEquals(412L, KadenceJson.decodeFromString<TimerStateDto>(grown).current?.id)
val policy = withUnknownKeys(fixture("blocker_policy_active.json"))
assertTrue(KadenceJson.decodeFromString<BlockerPolicyDto>(policy).active)
val tasks = withUnknownKeys(fixture("tasks.json"))
assertEquals(3, KadenceJson.decodeFromString<TaskListDto>(tasks).tasks.size)
}
@Test
fun `nulls where a value is declared fall back to defaults`() {
// `coerceInputValues` is what saves us here; without it this throws.
val nulled = allNulls(fixture("timer_state_running.json"))
val state = KadenceJson.decodeFromString<TimerStateDto>(nulled)
assertNull(state.current)
assertEquals(8, state.goal, "the default survives a null")
assertEquals("work", state.nextKind)
assertTrue(state.timerEnabled)
val policy = KadenceJson.decodeFromString<BlockerPolicyDto>(
allNulls(fixture("blocker_policy_active.json")),
)
assertTrue(!policy.active)
assertTrue(policy.allDomains.isEmpty())
}
@Test
fun `a nested object arriving as the wrong shape does not take the page down`() {
// A task row missing almost everything still decodes, because every field has
// a default — the list renders with blanks instead of crashing.
val json = """{"date":"2026-10-19","tasks":[{"id":9}],"counts":{},"sources":[]}"""
val list = KadenceJson.decodeFromString<TaskListDto>(json)
assertEquals(1, list.tasks.size)
assertEquals(9L, list.tasks[0].id)
assertEquals("", list.tasks[0].title)
assertEquals("someday", list.tasks[0].bucket)
}
@Test
fun `every fixture round-trips through the parser`() {
// Cheap guard that no fixture has drifted into invalid JSON.
val names = listOf(
"timer_state_running.json", "timer_state_idle.json", "timer_complete_reply.json",
"blocker_policy_active.json", "blocker_policy_inactive.json", "tasks.json",
"daily_report.json", "stats_summary.json", "block_events.json", "auth_me.json",
)
for (name in names) {
val parsed = KadenceJson.parseToJsonElement(fixture(name))
assertTrue(parsed is JsonObject, "$name should be a JSON object")
}
}
}
@@ -0,0 +1,225 @@
package tech.kadence.android
import kotlinx.coroutines.runBlocking
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import tech.kadence.android.data.dto.TimerStateDto
import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.data.net.AuthConfig
import tech.kadence.android.data.net.KadenceApi
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The HTTP layer, against a mock server.
*
* Nothing here needs the real backend running — the backend lives only on the dev
* machine, so a suite that required it would be unrunnable anywhere else. MockWebServer
* serves the committed fixtures instead.
*/
class KadenceApiTest {
private lateinit var server: MockWebServer
private var token: String? = "s3cret-token"
private val api: KadenceApi by lazy {
KadenceApi(config = {
object : AuthConfig {
override val baseUrl = server.url("/").toString().trimEnd('/')
override val apiToken = token
}
})
}
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name"))
.bufferedReader().readText()
private fun json(code: Int, body: String) =
MockResponse.Builder()
.code(code)
.addHeader("Content-Type", "application/json")
.body(body)
.build()
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.close()
}
@Test
fun `the api token goes out as X-API-Token, like the desktop client`() = runBlocking {
server.enqueue(json(200, fixture("timer_state_running.json")))
val result = api.get<TimerStateDto>("/api/timer/state")
assertIs<ApiResult.Ok<TimerStateDto>>(result)
assertEquals(412L, result.value.current?.id)
val recorded = server.takeRequest()
assertEquals("s3cret-token", recorded.headers["X-API-Token"])
assertEquals("application/json", recorded.headers["Accept"])
assertEquals("/api/timer/state", recorded.target)
}
@Test
fun `no token means no header, for a backend that wants none`() = runBlocking {
token = null
server.enqueue(json(200, fixture("timer_state_idle.json")))
api.get<TimerStateDto>("/api/timer/state")
assertNull(server.takeRequest().headers["X-API-Token"])
token = "s3cret-token"
}
@Test
fun `the session cookie is kept and sent back`() = runBlocking {
server.enqueue(
MockResponse.Builder()
.code(200)
.addHeader("Content-Type", "application/json")
.addHeader("Set-Cookie", "kadence_session=abc123; Path=/; HttpOnly; Max-Age=2592000")
.body("""{"ok":true}""")
.build(),
)
server.enqueue(json(200, fixture("timer_state_running.json")))
api.post<Map<String, Boolean>>("/api/auth/login", """{"username":"timka","password":"x"}""")
server.takeRequest()
api.get<TimerStateDto>("/api/timer/state")
val second = server.takeRequest()
assertTrue(
second.headers["Cookie"]?.contains("kadence_session=abc123") == true,
"the session cookie should ride along: ${second.headers["Cookie"]}",
)
assertEquals("abc123", api.cookieJar.named("kadence_session")?.value)
}
@Test
fun `a 401 on a normal path notifies so the UI can drop to login`() = runBlocking {
var notified = 0
api.unauthorized.subscribe { notified++ }
server.enqueue(json(401, """{"detail":"not authenticated"}"""))
val result = api.get<TimerStateDto>("/api/timer/state")
assertIs<ApiResult.Unauthorized>(result)
assertEquals("not authenticated", result.detail)
assertEquals(1, notified)
}
@Test
fun `a 401 on the login path does not, or signing in would loop`() = runBlocking {
var notified = 0
api.unauthorized.subscribe { notified++ }
server.enqueue(json(401, """{"detail":"wrong password"}"""))
val result = api.post<Map<String, Boolean>>("/api/auth/login", """{"username":"a"}""")
assertIs<ApiResult.HttpError>(result)
assertEquals(401, result.status)
assertEquals("wrong password", result.detail)
assertEquals(0, notified, "a failed sign-in must not trigger the re-auth bus")
}
@Test
fun `a plain detail string is surfaced as the message`() = runBlocking {
// What `TimerError` produces, e.g. refusing to pause when pausing is off.
server.enqueue(json(400, """{"detail":"Pausing is switched off in your settings."}"""))
val result = api.post<TimerStateDto>("/api/timer/pause")
assertIs<ApiResult.HttpError>(result)
assertEquals(400, result.status)
assertEquals("Pausing is switched off in your settings.", result.detail)
}
@Test
fun `a 422 validation body renders as field colon message`() = runBlocking {
server.enqueue(
json(
422,
"""{"detail":[
{"loc":["body","minutes"],"msg":"Input should be less than or equal to 240"},
{"loc":["body","kind"],"msg":"Input should be 'work', 'short_break' or 'long_break'"}
]}""",
),
)
val result = api.post<TimerStateDto>("/api/timer/start", """{"minutes":9999}""")
assertIs<ApiResult.HttpError>(result)
assertEquals(422, result.status)
assertEquals(
"minutes: Input should be less than or equal to 240; " +
"kind: Input should be 'work', 'short_break' or 'long_break'",
result.detail,
)
}
@Test
fun `a non-JSON error body falls back to the status line`() = runBlocking {
server.enqueue(
MockResponse.Builder().code(502).body("<html>Bad Gateway</html>").build(),
)
val result = api.get<TimerStateDto>("/api/timer/state")
assertIs<ApiResult.HttpError>(result)
assertEquals(502, result.status)
assertTrue(result.detail.startsWith("502"), "got: ${result.detail}")
}
@Test
fun `an unreachable backend is a NetworkError, never a crash`() = runBlocking {
server.close() // nothing listening any more
val result = api.get<TimerStateDto>("/api/timer/state")
assertIs<ApiResult.NetworkError>(result)
// Restart so tearDown has something to close.
server = MockWebServer().also { it.start() }
}
@Test
fun `a successful but unreadable body does not crash the caller`() = runBlocking {
server.enqueue(json(200, """{"current": "this should be an object"}"""))
val result = api.get<TimerStateDto>("/api/timer/state")
assertIs<ApiResult.HttpError>(result)
assertTrue(result.detail.startsWith("unreadable response"), "got: ${result.detail}")
}
@Test
fun `a POST with no body still sends an empty object`() = runBlocking {
// FastAPI's request models default every field, but an absent body on a POST
// that declares one is a 422; the desktop client sends `{}` for the same reason.
server.enqueue(json(200, fixture("timer_state_running.json")))
api.post<TimerStateDto>("/api/timer/resume")
val recorded = server.takeRequest()
assertEquals("POST", recorded.method)
assertEquals("{}", recorded.body?.utf8())
assertTrue(recorded.headers["Content-Type"]?.startsWith("application/json") == true)
}
@Test
fun `trailing slashes in the configured URL do not produce a double slash`() = runBlocking {
server.enqueue(json(200, fixture("timer_state_idle.json")))
api.get<TimerStateDto>("/api/timer/state")
assertEquals("/api/timer/state", server.takeRequest().target)
}
}
@@ -0,0 +1,135 @@
package tech.kadence.android
import androidx.compose.foundation.layout.Row
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.Modifier
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import tech.kadence.android.ui.components.TabularFigures
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.KADENCE_DARK
import tech.kadence.core.KADENCE_LIGHT
import kotlin.math.abs
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The theme and the type, on the JVM.
*
* Robolectric means the Compose layer is checked here rather than on the phone: the
* palette actually reaching a composable, and — the one that matters — that the serif
* clock really is fixed-advance despite Fraunces having no `tnum`.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ThemeTest {
@get:Rule
val compose = createComposeRule()
private fun argb(color: Color): Long = color.value.toLong() ushr 32 and 0xFFFFFFFFL
@Test
fun `the light palette reaching a composable is the core palette`() {
var paper: Color? = null
var ink: Color? = null
var rule: Color? = null
compose.setContent {
KadenceTheme(dark = false, dynamicAccent = false) {
paper = KadenceTheme.colors.paper
ink = KadenceTheme.colors.ink
rule = KadenceTheme.colors.rule
}
}
assertEquals(KADENCE_LIGHT.paper, argb(paper!!), "paper")
assertEquals(KADENCE_LIGHT.ink, argb(ink!!), "ink")
assertEquals(KADENCE_LIGHT.rule, argb(rule!!), "rule keeps its 12% alpha")
}
@Test
fun `the dark palette is the dark one, not a tinted light`() {
var paper: Color? = null
compose.setContent {
KadenceTheme(dark = true, dynamicAccent = false) {
paper = KadenceTheme.colors.paper
}
}
assertEquals(KADENCE_DARK.paper, argb(paper!!))
}
@Test
fun `without dynamic colour the accent is the brand ember`() {
var accent: Color? = null
var onAccent: Color? = null
compose.setContent {
KadenceTheme(dark = false, dynamicAccent = false) {
accent = KadenceTheme.colors.accent
onAccent = KadenceTheme.colors.onAccent
}
}
assertEquals(KADENCE_LIGHT.ember, argb(accent!!))
assertEquals(0xFFFFFFFFL, argb(onAccent!!), "white sits on ember")
}
@Test
fun `the chart palette does not follow the accent`() {
var work: Color? = null
var accent: Color? = null
compose.setContent {
KadenceTheme(dark = false, dynamicAccent = true) {
work = KadenceTheme.viz.work
accent = KadenceTheme.colors.accent
}
}
assertEquals(0xFFEB6834L, argb(work!!), "work keeps its validated orange")
assertTrue(work != accent || argb(accent!!) == 0xFFEB6834L)
}
@Test
fun `the serif clock is fixed-advance despite Fraunces having no tnum`() {
// The whole point of TabularFigures. "11:11" is the narrowest possible set of
// digits and "00:00" among the widest; with proportional figures these would
// differ by roughly 30% and the dial would visibly jiggle every second.
compose.setContent {
KadenceTheme(dark = false, dynamicAccent = false) {
Row {
TabularFigures(
text = "11:11",
style = KadenceTheme.type.clock,
color = KadenceTheme.colors.ink,
modifier = Modifier.testTag("ones"),
)
TabularFigures(
text = "00:00",
style = KadenceTheme.type.clock,
color = KadenceTheme.colors.ink,
modifier = Modifier.testTag("zeros"),
)
}
}
}
val ones = compose.onNodeWithTag("ones").getUnclippedBoundsInRoot()
val zeros = compose.onNodeWithTag("zeros").getUnclippedBoundsInRoot()
val onesWidth = (ones.right - ones.left).value
val zerosWidth = (zeros.right - zeros.left).value
// Guard against a stubbed text measurer: if both came back zero the comparison
// below would pass without proving anything.
assertTrue(onesWidth > 0f, "the figures did not actually measure")
assertTrue(
abs(onesWidth - zerosWidth) < 0.5f,
"11:11 measured $onesWidth dp and 00:00 measured $zerosWidth dp — " +
"the clock would jiggle every second",
)
}
}
@@ -0,0 +1,8 @@
{
"authenticated": true,
"login_required": true,
"username": "timka",
"password_login_enabled": true,
"telegram_login_enabled": false,
"telegram_bot_username": ""
}
@@ -0,0 +1,9 @@
{
"date": "2026-10-19",
"total": 16,
"by_target": [{"target": "youtube.com", "count": 12}, {"target": "reddit.com", "count": 4}],
"events": [
{"id": 90, "kind": "domain", "target": "youtube.com", "action": "blocked", "at": "2026-10-19T10:04:00+00:00", "details": "chrome"},
{"id": 91, "kind": "app", "target": "*discord*", "action": "killed", "at": "2026-10-19T10:06:00+00:00", "details": "Discord (com.discord)"}
]
}
@@ -0,0 +1,17 @@
{
"active": true,
"reason": "a work interval is running",
"mode": "during_work",
"enabled": true,
"strict": false,
"redirect_ip": "127.0.0.1",
"domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
"apps": ["steam*", "*discord*"],
"unexpandable": ["*gram*"],
"kill_apps": true,
"notify_on_block": true,
"poll_seconds": 5,
"session_id": 412,
"all_domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
"all_apps": ["steam*", "*discord*"]
}
@@ -0,0 +1,17 @@
{
"active": false,
"reason": "no work interval is running",
"mode": "during_work",
"enabled": true,
"strict": false,
"redirect_ip": "127.0.0.1",
"domains": [],
"apps": [],
"unexpandable": ["*gram*"],
"kill_apps": true,
"notify_on_block": true,
"poll_seconds": 5,
"session_id": null,
"all_domains": ["youtube.com", "www.youtube.com", "m.youtube.com"],
"all_apps": ["steam*", "*discord*"]
}
@@ -0,0 +1,41 @@
{
"report": {
"date": "2026-10-19",
"metrics": {
"date": "2026-10-19", "pomodoros": 6, "goal": 8, "goal_pct": 75.0, "goal_met": false,
"focus_minutes": 155, "break_minutes": 35, "wasted_minutes": 12, "abandoned": 1,
"breaks_taken": 4, "interruptions": 3, "completion_rate": 0.86,
"longest_stretch_minutes": 80, "first_start": "2026-10-19T09:12:00+00:00",
"last_end": "2026-10-19T18:40:00+00:00",
"by_hour": [{"hour": 9, "count": 1}, {"hour": 10, "count": 2}, {"hour": 14, "count": 3}],
"peak_hour": 10,
"tasks_completed": [
{"id": 1, "title": "Fix the flaky importer", "source": "jira", "project": "Platform", "url": "https://example.atlassian.net/browse/PLAT-184"}
],
"tasks_completed_count": 1,
"time_by_task": [{"title": "Fix the flaky importer", "minutes": 75}],
"blocked_attempts": 16,
"blocked_top": [{"target": "youtube.com", "count": 12}, {"target": "reddit.com", "count": 4}],
"sessions": [
{"id": 412, "kind": "work", "status": "completed", "started_at": "2026-10-19T09:12:00+00:00", "minutes": 25, "task": "Fix the flaky importer", "interruptions": 1}
]
},
"streak": {"current": 4, "best": 11, "goal_streak": 2, "total_days": 63},
"average_7d": {"pomodoros": 5.4, "focus_minutes": 142.3},
"lifetime": {"pomodoros": 390, "focus_minutes": 9750, "focus_hours": 162.5, "tasks_completed": 88, "blocked_attempts": 512},
"comparison": {"yesterday": 7, "previous_best": 11},
"achievements": [
{"code": "deep_work", "emoji": "🧘", "title": "Deep work", "detail": "80 minutes without breaking the chain."},
{"code": "gatekeeper", "emoji": "🛡️", "title": "Gatekeeper", "detail": "The blocker turned you away 16 times. It won."}
],
"fun_facts": [
"155 minutes of focus — that is 7.0 sitcom episodes you did not watch.",
"Your peak hour was 10:00. Defend it."
],
"verdict": "Target hit. Now protect the streak. 💪",
"open_tasks": {"overdue": 1, "today": 1, "someday": 1},
"score": 72.4
},
"sent_at": null,
"telegram_preview": "🍅 Daily report — 2026-10-19"
}
@@ -0,0 +1,20 @@
{
"today": {
"date": "2026-10-19", "pomodoros": 6, "goal": 8, "goal_pct": 75.0, "goal_met": false,
"focus_minutes": 155, "break_minutes": 35, "interruptions": 3,
"by_hour": [{"hour": 10, "count": 2}], "peak_hour": 10,
"blocked_attempts": 16, "sessions": []
},
"streak": {"current": 4, "best": 11, "goal_streak": 2, "total_days": 63},
"lifetime": {"pomodoros": 390, "focus_minutes": 9750, "focus_hours": 162.5, "tasks_completed": 88, "blocked_attempts": 512},
"average_7d": {"pomodoros": 5.4, "focus_minutes": 142.3},
"range": [
{"date": "2026-10-17", "pomodoros": 5, "focus_minutes": 125, "goal_met": false},
{"date": "2026-10-18", "pomodoros": 7, "focus_minutes": 175, "goal_met": false},
{"date": "2026-10-19", "pomodoros": 6, "focus_minutes": 155, "goal_met": false}
],
"heatmap": [
{"date": "2026-10-13", "pomodoros": 8, "focus_minutes": 200, "goal_met": true},
{"date": "2026-10-14", "pomodoros": 0, "focus_minutes": 0, "goal_met": false}
]
}
@@ -0,0 +1,32 @@
{
"date": "2026-10-19",
"tasks": [
{
"id": 1, "source": "jira", "source_label": "Jira", "external_id": "PLAT-184",
"title": "Fix the flaky importer", "description": null,
"url": "https://example.atlassian.net/browse/PLAT-184",
"project": "Platform", "status": "In Progress", "priority": "High",
"due_date": "2026-10-16", "start_date": null, "labels": ["backend"],
"is_done": false, "pinned": true, "stale": false,
"estimated_pomodoros": 3, "days_overdue": 3, "bucket": "overdue"
},
{
"id": 2, "source": "todoist", "source_label": "Todoist", "external_id": "7788",
"title": "Write the design doc", "description": null, "url": null,
"project": "Work", "status": null, "priority": "p2",
"due_date": "2026-10-19", "start_date": null, "labels": [],
"is_done": false, "pinned": false, "stale": false,
"estimated_pomodoros": null, "days_overdue": 0, "bucket": "today"
},
{
"id": 3, "source": "local", "source_label": "Local", "external_id": "local-3",
"title": "And this too", "description": null, "url": null,
"project": null, "status": null, "priority": null,
"due_date": null, "start_date": null, "labels": [],
"is_done": false, "pinned": false, "stale": false,
"estimated_pomodoros": null, "days_overdue": 0, "bucket": "someday"
}
],
"counts": {"overdue": 1, "today": 1, "active": 0, "upcoming": 0, "someday": 1},
"sources": ["jira", "todoist", "local"]
}
@@ -0,0 +1,52 @@
{
"current": {
"id": 413,
"kind": "short_break",
"status": "running",
"started_at": "2026-10-19T09:37:04+00:00",
"ended_at": null,
"planned_seconds": 300,
"elapsed_seconds": 0,
"remaining_seconds": 300,
"paused_seconds": 0,
"interruptions": 0,
"cycle_index": 3,
"task": null,
"note": null
},
"finished": {
"id": 412,
"kind": "work",
"status": "completed",
"started_at": "2026-10-19T09:12:04+00:00",
"ended_at": "2026-10-19T09:37:04+00:00",
"planned_seconds": 1500,
"elapsed_seconds": 1500,
"remaining_seconds": 0,
"paused_seconds": 0,
"interruptions": 1,
"cycle_index": 2,
"task": {"source": "jira", "external_id": "PLAT-184", "title": "Fix the flaky importer"},
"note": null
},
"auto_started": {
"id": 413,
"kind": "short_break",
"status": "running",
"started_at": "2026-10-19T09:37:04+00:00",
"planned_seconds": 300,
"remaining_seconds": 300,
"interruptions": 0,
"cycle_index": 3,
"task": null
},
"celebration": "Seven down. The afternoon is yours to lose.",
"next_kind": "work",
"completed_today": 7,
"goal": 8,
"cycle_position": 3,
"cycles_before_long_break": 4,
"durations": {"work": 25, "short_break": 5, "long_break": 15},
"server_time": "2026-10-19T09:37:04+00:00",
"timer_enabled": true
}
@@ -0,0 +1,11 @@
{
"current": null,
"next_kind": "work",
"completed_today": 0,
"goal": 8,
"cycle_position": 0,
"cycles_before_long_break": 4,
"durations": {"work": 45, "short_break": 15, "long_break": 30},
"server_time": "2026-10-19T08:00:00+00:00",
"timer_enabled": true
}
@@ -0,0 +1,29 @@
{
"current": {
"id": 412,
"kind": "work",
"status": "running",
"started_at": "2026-10-19T09:12:04+00:00",
"ended_at": null,
"planned_seconds": 1500,
"elapsed_seconds": 396,
"remaining_seconds": 1104,
"paused_seconds": 0,
"interruptions": 1,
"cycle_index": 2,
"task": {
"source": "jira",
"external_id": "PLAT-184",
"title": "Fix the flaky importer"
},
"note": null
},
"next_kind": "short_break",
"completed_today": 6,
"goal": 8,
"cycle_position": 2,
"cycles_before_long_break": 4,
"durations": {"work": 25, "short_break": 5, "long_break": 15},
"server_time": "2026-10-19T09:18:40+00:00",
"timer_enabled": true
}