diff --git a/README.md b/README.md
index e3193b7..deda8ff 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,11 @@ Android home screen widget that shows your Claude Pro usage at a glance.
- **SESSION** bar — current 5-hour window utilization with reset time
- **WEEKLY** bar — 7-day rolling usage with reset time
+- **Pace marker** — a colored tick on each bar shows where you *should be* right now to finish at
+ exactly 100% by reset. Tick color grades your projection: green (way under budget) → teal →
+ yellow → orange → red → purple (burning way too fast), with an "X% over/under pace" label.
+- **Peak-hours indicator** — a Claude burst icon that lights up 🔥 during Anthropic's peak window
+ (5–11 AM Pacific, Mon–Fri), when tokens burn faster, with a countdown to the window close.
- Tap the widget to open the app; tap ⟳ to force-refresh
- Responsive: works as 4×1 (compact) or 4×2 (full)
- Auto-refreshes every 5 minutes in the background
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1c9e69a..8b67400 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,8 +11,8 @@ android {
applicationId = "me.khodak.claudeusage"
minSdk = 26
targetSdk = 34
- versionCode = 11
- versionName = "1.10"
+ versionCode = 12
+ versionName = "1.11"
}
signingConfigs {
diff --git a/app/src/main/java/me/khodak/claudeusage/BarRenderer.kt b/app/src/main/java/me/khodak/claudeusage/BarRenderer.kt
new file mode 100644
index 0000000..080a1b7
--- /dev/null
+++ b/app/src/main/java/me/khodak/claudeusage/BarRenderer.kt
@@ -0,0 +1,57 @@
+package me.khodak.claudeusage
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+
+/**
+ * Draws a usage bar as a Bitmap: rounded track + rounded fill + an optional vertical pace tick.
+ * Used both in the home-screen widget (setImageViewBitmap) and the in-app card, so the two render
+ * identically. Bitmaps are rendered at a fixed width and stretched horizontally via ImageView
+ * scaleType=fitXY; height is fixed so there is no vertical distortion.
+ */
+object BarRenderer {
+
+ private const val TRACK_COLOR = 0xFF252525.toInt()
+
+ fun render(
+ usedPct: Int,
+ markerPct: Int?,
+ fillColor: Int,
+ tierColor: Int?,
+ wPx: Int = 500,
+ hPx: Int = 14,
+ cornerPx: Float = 7f
+ ): Bitmap {
+ val bmp = Bitmap.createBitmap(wPx, hPx, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bmp)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+
+ // Track
+ paint.color = TRACK_COLOR
+ val full = RectF(0f, 0f, wPx.toFloat(), hPx.toFloat())
+ canvas.drawRoundRect(full, cornerPx, cornerPx, paint)
+
+ // Fill
+ val pct = usedPct.coerceIn(0, 100)
+ if (pct > 0) {
+ paint.color = fillColor
+ val fillW = (wPx * pct / 100f).coerceAtLeast(cornerPx * 2)
+ val fill = RectF(0f, 0f, fillW, hPx.toFloat())
+ canvas.drawRoundRect(fill, cornerPx, cornerPx, paint)
+ }
+
+ // Pace tick — "where you should be right now"
+ if (markerPct != null && tierColor != null) {
+ val m = markerPct.coerceIn(0, 100)
+ val tickW = (wPx * 0.012f).coerceIn(3f, 7f)
+ var x = wPx * m / 100f
+ x = x.coerceIn(tickW / 2f, wPx - tickW / 2f)
+ paint.color = tierColor
+ canvas.drawRect(x - tickW / 2f, 0f, x + tickW / 2f, hPx.toFloat(), paint)
+ }
+
+ return bmp
+ }
+}
diff --git a/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt b/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt
index 67c2924..b3e1c20 100644
--- a/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt
+++ b/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt
@@ -87,14 +87,15 @@ class ClaudeUsageWidget : AppWidgetProvider() {
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
+ applyPeak(v, showText = false)
if (!prefs.isLoggedIn()) {
v.setTextViewText(R.id.tv_session_value, "—")
v.setTextViewText(R.id.tv_session_label, "Not signed in")
v.setTextViewText(R.id.tv_weekly_value, "—")
v.setTextViewText(R.id.tv_weekly_label, "")
v.setTextViewText(R.id.tv_status, "")
- v.setProgressBar(R.id.progress_bar, 100, 0, false)
- v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
+ v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
+ v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
return v
}
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
@@ -103,15 +104,17 @@ class ClaudeUsageWidget : AppWidgetProvider() {
when {
hasUtilization -> {
val pct = apiData!!.fiveHourUtilization.toInt()
+ val pace = PaceCalc.compute(apiData.fiveHourUtilization, apiData.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS)
v.setTextViewText(R.id.tv_session_value, "$pct%")
- v.setProgressBar(R.id.progress_bar, 100, pct, false)
- v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
+ v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, pace?.markerPct, SESSION_FILL, pace?.tierColor))
+ v.setTextViewText(R.id.tv_session_label,
+ if (pace != null) PaceCalc.shortTag(apiData.fiveHourUtilization, pace) else formatReset(apiData.utilizationResetAtEpoch))
}
hasApiMessages -> {
val rem = apiData!!.effectiveRemaining
val lim = apiData.messagesLimit
v.setTextViewText(R.id.tv_session_value, "$rem/$lim")
- v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false)
+ v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
}
sessionStart > 0 -> {
@@ -119,25 +122,27 @@ class ClaudeUsageWidget : AppWidgetProvider() {
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
v.setTextViewText(R.id.tv_session_value, if (h > 0) "${h}h${m}m" else "${m}m")
- v.setProgressBar(R.id.progress_bar, 100, 0, false)
+ v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "active")
}
else -> {
v.setTextViewText(R.id.tv_session_value, "—")
- v.setProgressBar(R.id.progress_bar, 100, 0, false)
+ v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "")
}
}
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
if (hasWeekly) {
val wPct = apiData!!.weeklyUtilization.toInt()
+ val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
- v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false)
- v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
+ v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, pace?.tierColor))
+ v.setTextViewText(R.id.tv_weekly_label,
+ if (pace != null) PaceCalc.shortTag(apiData.weeklyUtilization, pace) else formatReset(apiData.weeklyResetAtEpoch))
} else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d")
- v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
+ v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
v.setTextViewText(R.id.tv_weekly_label, "")
}
val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
@@ -157,6 +162,7 @@ class ClaudeUsageWidget : AppWidgetProvider() {
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
val v = RemoteViews(context.packageName, R.layout.widget_layout)
+ applyPeak(v, showText = true)
// ── Not logged in ────────────────────────────────────────────────
if (!prefs.isLoggedIn()) {
@@ -165,7 +171,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
v.setTextViewText(R.id.tv_weekly_value, "—")
v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in")
v.setTextViewText(R.id.tv_status, "")
- v.setProgressBar(R.id.progress_bar, 100, 0, false)
+ v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
+ v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
return v
}
@@ -177,15 +184,16 @@ class ClaudeUsageWidget : AppWidgetProvider() {
when {
hasUtilization -> {
val pct = apiData!!.fiveHourUtilization.toInt()
+ val pace = PaceCalc.compute(apiData.fiveHourUtilization, apiData.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS)
v.setTextViewText(R.id.tv_session_value, "$pct%")
- v.setProgressBar(R.id.progress_bar, 100, pct, false)
- v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
+ v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, pace?.markerPct, SESSION_FILL, pace?.tierColor))
+ v.setTextViewText(R.id.tv_session_label, resetWithPace(apiData.utilizationResetAtEpoch, apiData.fiveHourUtilization, pace))
}
hasApiMessages -> {
val rem = apiData!!.effectiveRemaining
val lim = apiData.messagesLimit
v.setTextViewText(R.id.tv_session_value, "$rem / $lim")
- v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false)
+ v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
}
sessionStart > 0 -> {
@@ -194,12 +202,12 @@ class ClaudeUsageWidget : AppWidgetProvider() {
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
val display = if (h > 0) "${h}h ${m}m" else if (m > 0) "${m}m" else "Just started"
v.setTextViewText(R.id.tv_session_value, display)
- v.setProgressBar(R.id.progress_bar, 100, 0, false)
+ v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "session active")
}
else -> {
v.setTextViewText(R.id.tv_session_value, "—")
- v.setProgressBar(R.id.progress_bar, 100, 0, false)
+ v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "")
}
}
@@ -208,13 +216,14 @@ class ClaudeUsageWidget : AppWidgetProvider() {
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
if (hasWeekly) {
val wPct = apiData!!.weeklyUtilization.toInt()
+ val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
- v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false)
- v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
+ v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, pace?.tierColor))
+ v.setTextViewText(R.id.tv_weekly_label, resetWithPace(apiData.weeklyResetAtEpoch, apiData.weeklyUtilization, pace))
} else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d")
- v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
+ v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
v.setTextViewText(R.id.tv_weekly_label, "active this week")
}
@@ -238,6 +247,27 @@ class ClaudeUsageWidget : AppWidgetProvider() {
return v
}
+ private const val SESSION_FILL = 0xFFCC785C.toInt()
+ private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
+
+ /** Tints the header burst icon and (optionally) the PEAK text by current peak state. */
+ private fun applyPeak(v: RemoteViews, showText: Boolean) {
+ val peak = PeakHours.isPeak()
+ v.setInt(R.id.img_peak, "setColorFilter",
+ if (peak) 0xFFCC785C.toInt() else 0xFF666666.toInt())
+ if (showText) {
+ v.setTextViewText(R.id.tv_peak, if (peak) "🔥 PEAK" else "")
+ }
+ }
+
+ /** "Resets at 3:00 PM · 8% under pace" — reset line with the pace tag appended. */
+ private fun resetWithPace(resetEpoch: Long, usedPct: Float, pace: PaceCalc.Pace?): CharSequence {
+ val reset = formatReset(resetEpoch)
+ if (pace == null) return reset
+ val tag = PaceCalc.shortTag(usedPct, pace)
+ return if (reset.isBlank()) tag else "$reset · $tag"
+ }
+
private fun formatReset(epochMs: Long): String {
if (epochMs <= 0) return ""
val now = System.currentTimeMillis()
diff --git a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt
index b9dc91f..7ec81cc 100644
--- a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt
+++ b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt
@@ -104,15 +104,38 @@ class MainActivity : AppCompatActivity() {
if (!loggedIn || data == null) return
- binding.progressBar.progress = data.progressPercent
+ // ── Peak-hours row ───────────────────────────────────────────────────
+ val peak = PeakHours.state()
+ binding.imgPeak.setColorFilter(if (peak.active) PEAK_ON else PEAK_OFF)
+ binding.tvPeak.setTextColor(if (peak.active) PEAK_ON else 0xFFAAAAAA.toInt())
+ binding.tvPeak.text = if (peak.active)
+ "🔥 Peak hours — ${peak.endsInLabel} · ${peak.windowLabel}"
+ else
+ "Off-peak · ${peak.windowLabel}"
+ // ── Session (5-hour) bar + pace ──────────────────────────────────────
+ val sessionPct = data.progressPercent
+ val sessionPace = if (data.fiveHourUtilization >= 0f)
+ PaceCalc.compute(data.fiveHourUtilization, data.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS)
+ else null
+ binding.barSession.setImageBitmap(
+ BarRenderer.render(sessionPct, sessionPace?.markerPct, SESSION_FILL, sessionPace?.tierColor)
+ )
+ binding.tvSessionPace.text = paceSentence(data.fiveHourUtilization, sessionPace)
+
+ // ── Weekly (7-day) bar + pace ────────────────────────────────────────
if (data.weeklyUtilization >= 0f) {
val wPct = data.weeklyUtilization.toInt()
- binding.progressBarWeekly.progress = wPct
+ val weeklyPace = PaceCalc.compute(data.weeklyUtilization, data.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
+ binding.barWeekly.setImageBitmap(
+ BarRenderer.render(wPct, weeklyPace?.markerPct, WEEKLY_FILL, weeklyPace?.tierColor)
+ )
binding.tvWeeklyUsage.text = "$wPct% this week"
+ binding.tvWeeklyPace.text = paceSentence(data.weeklyUtilization, weeklyPace)
} else {
- binding.progressBarWeekly.progress = 0
+ binding.barWeekly.setImageBitmap(BarRenderer.render(0, null, WEEKLY_FILL, null))
binding.tvWeeklyUsage.text = "—"
+ binding.tvWeeklyPace.text = ""
}
binding.tvUsage.text = when {
@@ -139,6 +162,12 @@ class MainActivity : AppCompatActivity() {
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
}
+ /** "20% over pace · will likely hit limit" — empty when no projection is available yet. */
+ private fun paceSentence(usedPct: Float, pace: PaceCalc.Pace?): String {
+ if (pace == null) return ""
+ return "${PaceCalc.shortTag(usedPct, pace)} · ${pace.label}"
+ }
+
private fun formatReset(epochMs: Long): String {
if (epochMs <= 0) return ""
val now = System.currentTimeMillis()
@@ -151,4 +180,11 @@ class MainActivity : AppCompatActivity() {
else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr"
}
}
+
+ companion object {
+ private const val SESSION_FILL = 0xFFCC785C.toInt()
+ private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
+ private const val PEAK_ON = 0xFFCC785C.toInt()
+ private const val PEAK_OFF = 0xFF666666.toInt()
+ }
}
diff --git a/app/src/main/java/me/khodak/claudeusage/PaceCalc.kt b/app/src/main/java/me/khodak/claudeusage/PaceCalc.kt
new file mode 100644
index 0000000..4f3dfa3
--- /dev/null
+++ b/app/src/main/java/me/khodak/claudeusage/PaceCalc.kt
@@ -0,0 +1,75 @@
+package me.khodak.claudeusage
+
+/**
+ * Pace projection mirroring hamed-elfayome/Claude-Usage-Tracker's PaceStatus.
+ *
+ * Idea: if you keep your current burn rate, where do you land at reset?
+ * projected = (usedPct / 100) / elapsedFraction
+ * The "where you should be right now" marker sits at elapsedFraction*100 on the bar —
+ * i.e. the position that finishes at exactly 100% by reset.
+ */
+object PaceCalc {
+
+ const val SESSION_WINDOW_MS = 5L * 60 * 60 * 1000 // 5 hours
+ const val WEEKLY_WINDOW_MS = 7L * 24 * 60 * 60 * 1000 // 7 days
+
+ // System-style tier colors (ARGB).
+ private const val GREEN = 0xFF34C759.toInt()
+ private const val TEAL = 0xFF30B0C7.toInt()
+ private const val YELLOW = 0xFFFFCC00.toInt()
+ private const val ORANGE = 0xFFFF9500.toInt()
+ private const val RED = 0xFFFF3B30.toInt()
+ private const val PURPLE = 0xFFAF52DE.toInt()
+
+ data class Pace(
+ val markerPct: Int, // where you "should be" now (0..100)
+ val projected: Float, // projected end-of-period fraction (1.0 == exactly 100%)
+ val tierColor: Int, // ARGB color for the tier
+ val label: String // short interpretation, e.g. "sustainable pace"
+ )
+
+ /**
+ * @param usedPct current utilization 0..100
+ * @param resetEpoch epoch millis when this window resets (period end)
+ * @param windowMs total length of the window
+ * @return Pace, or null if we can't meaningfully project yet
+ */
+ fun compute(
+ usedPct: Float,
+ resetEpoch: Long,
+ windowMs: Long,
+ now: Long = System.currentTimeMillis()
+ ): Pace? {
+ if (resetEpoch <= 0L || usedPct < 0f) return null
+ val start = resetEpoch - windowMs
+ val elapsed = now - start
+ val elapsedFraction = elapsed.toFloat() / windowMs.toFloat()
+ // Match reference: need >=3% elapsed and period not complete.
+ if (elapsedFraction < 0.03f || elapsedFraction >= 1.0f) return null
+
+ val markerPct = (elapsedFraction * 100f).toInt().coerceIn(0, 100)
+ if (usedPct == 0f) {
+ return Pace(markerPct, 0f, GREEN, "way under budget")
+ }
+ val projected = (usedPct / 100f) / elapsedFraction
+ val (color, label) = when {
+ projected < 0.50f -> GREEN to "way under budget"
+ projected < 0.75f -> TEAL to "sustainable pace"
+ projected < 0.90f -> YELLOW to "starting to push it"
+ projected < 1.00f -> ORANGE to "will likely hit limit"
+ projected < 1.20f -> RED to "on track to exceed"
+ else -> PURPLE to "burning way too fast"
+ }
+ return Pace(markerPct, projected, color, label)
+ }
+
+ /** Short tag for the widget reset line, e.g. "8% under pace" / "20% over pace" / "on pace". */
+ fun shortTag(usedPct: Float, pace: Pace): String {
+ val delta = usedPct.toInt() - pace.markerPct
+ return when {
+ delta >= 2 -> "$delta% over pace"
+ delta <= -2 -> "${-delta}% under pace"
+ else -> "on pace"
+ }
+ }
+}
diff --git a/app/src/main/java/me/khodak/claudeusage/PeakHours.kt b/app/src/main/java/me/khodak/claudeusage/PeakHours.kt
new file mode 100644
index 0000000..cffa8a4
--- /dev/null
+++ b/app/src/main/java/me/khodak/claudeusage/PeakHours.kt
@@ -0,0 +1,80 @@
+package me.khodak.claudeusage
+
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.GregorianCalendar
+import java.util.Locale
+import java.util.TimeZone
+import java.util.concurrent.TimeUnit
+
+/**
+ * Anthropic peak usage window, mirroring hamed-elfayome/Claude-Usage-Tracker's PeakHoursService:
+ * 5:00–11:00 AM America/Los_Angeles, Monday–Friday. Tokens burn faster during this window.
+ */
+object PeakHours {
+
+ private const val PEAK_START_HOUR = 5 // 5 AM PT inclusive
+ private const val PEAK_END_HOUR = 11 // 11 AM PT exclusive
+ private val PT: TimeZone = TimeZone.getTimeZone("America/Los_Angeles")
+
+ data class PeakState(
+ val active: Boolean,
+ val endsInLabel: String, // e.g. "ends in 2h 14m" (only meaningful when active)
+ val windowLabel: String // e.g. "5–11 AM PT · 8 AM–2 PM your time"
+ )
+
+ fun isPeak(now: Long = System.currentTimeMillis()): Boolean {
+ val cal = GregorianCalendar(PT).apply { timeInMillis = now }
+ val dow = cal.get(Calendar.DAY_OF_WEEK) // Sun=1 .. Sat=7
+ val weekday = dow in Calendar.MONDAY..Calendar.FRIDAY
+ val hour = cal.get(Calendar.HOUR_OF_DAY)
+ return weekday && hour in PEAK_START_HOUR until PEAK_END_HOUR
+ }
+
+ /** Epoch millis of 11:00 AM PT on the current PT day (peak window close). */
+ fun peakEndEpoch(now: Long = System.currentTimeMillis()): Long {
+ val cal = GregorianCalendar(PT).apply {
+ timeInMillis = now
+ set(Calendar.HOUR_OF_DAY, PEAK_END_HOUR)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+ return cal.timeInMillis
+ }
+
+ fun state(now: Long = System.currentTimeMillis()): PeakState {
+ val active = isPeak(now)
+ val endsIn = if (active) {
+ val rem = peakEndEpoch(now) - now
+ val h = TimeUnit.MILLISECONDS.toHours(rem)
+ val m = TimeUnit.MILLISECONDS.toMinutes(rem) % 60
+ if (h > 0) "ends in ${h}h ${m}m" else "ends in ${m}m"
+ } else ""
+ return PeakState(active, endsIn, localWindowString(now))
+ }
+
+ /** "5–11 AM PT · 8 AM–2 PM your time" — peak window translated to the device timezone. */
+ fun localWindowString(now: Long = System.currentTimeMillis()): String {
+ val local = TimeZone.getDefault()
+ if (local.id == PT.id) return "5–11 AM PT"
+ val startLocal = ptHourToLocal(PEAK_START_HOUR, now, local)
+ val endLocal = ptHourToLocal(PEAK_END_HOUR, now, local)
+ val fmt = SimpleDateFormat("h a", Locale.US).apply { timeZone = local }
+ val s = fmt.format(Date(startLocal)).replace(" ", " ")
+ val e = fmt.format(Date(endLocal)).replace(" ", " ")
+ return "5–11 AM PT · $s–$e your time"
+ }
+
+ private fun ptHourToLocal(ptHour: Int, now: Long, local: TimeZone): Long {
+ val cal = GregorianCalendar(PT).apply {
+ timeInMillis = now
+ set(Calendar.HOUR_OF_DAY, ptHour)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+ return cal.timeInMillis // absolute instant; formatting in `local` renders local wall time
+ }
+}
diff --git a/app/src/main/res/drawable/ic_claude_burst.xml b/app/src/main/res/drawable/ic_claude_burst.xml
new file mode 100644
index 0000000..d70d468
--- /dev/null
+++ b/app/src/main/res/drawable/ic_claude_burst.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index eb22b44..03e26dd 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -92,6 +92,33 @@
android:background="@drawable/widget_background"
android:padding="20dp">
+
+
+
+
+
+
+
+
+
-
+ android:scaleType="fitXY"
+ android:adjustViewBounds="false"
+ android:contentDescription="Session usage bar" />
+
+
-
+ android:scaleType="fitXY"
+ android:adjustViewBounds="false"
+ android:contentDescription="Weekly usage bar" />
+
+
+
+
+
+
-
+ android:scaleType="fitXY"
+ android:adjustViewBounds="false"
+ android:contentDescription="Session usage bar" />
-
+ android:scaleType="fitXY"
+ android:adjustViewBounds="false"
+ android:contentDescription="Weekly usage bar" />
+
+
-
+ android:scaleType="fitXY"
+ android:adjustViewBounds="false"
+ android:contentDescription="Session usage bar" />
-
+ android:scaleType="fitXY"
+ android:adjustViewBounds="false"
+ android:contentDescription="Weekly usage bar" />