Skip to content

Commit 984ee09

Browse files
committed
move timezone to server side
1 parent 99c8949 commit 984ee09

File tree

1 file changed

+69
-70
lines changed

1 file changed

+69
-70
lines changed

service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillShowAction.kt

Lines changed: 69 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import app.cash.backfila.ui.components.PageTitle
1212
import app.cash.backfila.ui.components.PaginationWithHistory
1313
import app.cash.backfila.ui.components.ProgressBar
1414
import app.cash.backfila.ui.pages.BackfillCreateAction.BackfillCreateField.CUSTOM_PARAMETER_PREFIX
15+
import java.net.URLDecoder
16+
import java.time.Instant
17+
import java.time.ZoneId
18+
import java.time.format.DateTimeFormatter
1519
import javax.inject.Inject
1620
import javax.inject.Singleton
1721
import kotlinx.html.ButtonType
@@ -36,10 +40,12 @@ import kotlinx.html.td
3640
import kotlinx.html.th
3741
import kotlinx.html.thead
3842
import kotlinx.html.tr
43+
import misk.scope.ActionScoped
3944
import misk.security.authz.Authenticated
4045
import misk.tailwind.Link
4146
import misk.turbo.turbo_frame
4247
import misk.web.Get
48+
import misk.web.HttpCall
4349
import misk.web.PathParam
4450
import misk.web.QueryParam
4551
import misk.web.Response
@@ -54,6 +60,7 @@ class BackfillShowAction @Inject constructor(
5460
private val dashboardPageLayout: DashboardPageLayout,
5561
private val viewLogsAction: ViewLogsAction,
5662
private val backfillShowButtonHandlerAction: BackfillShowButtonHandlerAction,
63+
private val httpCall: ActionScoped<HttpCall>,
5764
) : WebAction {
5865
@Get(PATH)
5966
@ResponseContentType(MediaTypes.TEXT_HTML)
@@ -68,6 +75,9 @@ class BackfillShowAction @Inject constructor(
6875
val label =
6976
if (backfill.variant == "default") backfill.service_name else "${backfill.service_name} (${backfill.variant})"
7077

78+
// Get user's timezone from cookie
79+
val userTimezone = getUserTimezone()
80+
7181
val configurationRows = backfill.toConfigurationRows(id)
7282
val leftColumnConfigurationRows = configurationRows.take(
7383
configurationRows.size / 2 +
@@ -79,9 +89,8 @@ class BackfillShowAction @Inject constructor(
7989
val htmlResponseBody = dashboardPageLayout.newBuilder()
8090
.title("Backfill $id | Backfila")
8191
.headBlock {
82-
// Add JavaScript to format timestamps in user's timezone
8392
script {
84-
+formatToLocalTimestampsScript()
93+
+timezoneDetectionScript()
8594
}
8695
}
8796
.breadcrumbLinks(
@@ -140,7 +149,7 @@ class BackfillShowAction @Inject constructor(
140149
h2("text-base font-semibold leading-6 text-gray-900") { +"""Configuration""" }
141150
dl("divide-y divide-gray-100") {
142151
leftColumnConfigurationRows.map {
143-
ConfigurationRows(id, it, backfill)
152+
ConfigurationRows(id, it, backfill, userTimezone)
144153
}
145154
}
146155
}
@@ -149,7 +158,7 @@ class BackfillShowAction @Inject constructor(
149158
div("divide-x divide-gray-100") {
150159
dl("divide-y divide-gray-100") {
151160
rightColumnConfigurationRows.map {
152-
ConfigurationRows(id, it, backfill)
161+
ConfigurationRows(id, it, backfill, userTimezone)
153162
}
154163
}
155164
}
@@ -172,20 +181,20 @@ class BackfillShowAction @Inject constructor(
172181
div("my-6 space-y-4") {
173182
div("text-sm text-gray-700") {
174183
span("font-medium") { +"""Total backfilled ${backfill.unit ?: "units (records, segments, bytes)"}: """ }
175-
span("font-semibold text-gray-900") { +"""${totalBackfilledItems.toString().replace(Regex("(\\d)(?=(\\d{3})+(?!\\d))"), "$1,")}""" }
184+
span("font-semibold text-gray-900") { +formatNumber(totalBackfilledItems) }
176185
}
177186
div("text-sm text-gray-700") {
178187
span("font-medium") { +"""Total ${backfill.unit ?: "units (records, segments, bytes)"} to run: """ }
179188
if (allPrecomputingDone) {
180-
span("font-semibold text-gray-900") { +"""${totalItemsToRun.toString().replace(Regex("(\\d)(?=(\\d{3})+(?!\\d))"), "$1,")}""" }
189+
span("font-semibold text-gray-900") { +formatNumber(totalItemsToRun) }
181190
} else {
182-
span("font-semibold text-gray-900") { +"""at least ${totalItemsToRun.toString().replace(Regex("(\\d)(?=(\\d{3})+(?!\\d))"), "$1,")} (still computing)""" }
191+
span("font-semibold text-gray-900") { +"""at least ${formatNumber(totalItemsToRun)} (still computing)""" }
183192
}
184193
}
185194
div("text-sm text-gray-700") {
186195
span("font-medium") { +"""Overall Rate: """ }
187196
if (totalRate > 0) {
188-
span("font-semibold text-gray-900") { +"""${totalRate.toString().replace(Regex("(\\d)(?=(\\d{3})+(?!\\d))"), "$1,")} #/m""" }
197+
span("font-semibold text-gray-900") { +"""${formatNumber(totalRate.toLong())} #/m""" }
189198
} else {
190199
span("font-semibold text-gray-900") { +"""N/A""" }
191200
}
@@ -362,11 +371,7 @@ class BackfillShowAction @Inject constructor(
362371
backfill.event_logs.map { log ->
363372
tr("border-b border-gray-100") {
364373
td("hidden py-5 pl-8 pr-0 align-top text-wrap text-gray-700 sm:table-cell") {
365-
span {
366-
attributes["data-timestamp"] = log.occurred_at.toString()
367-
attributes["class"] = "localized-time"
368-
+log.occurred_at.toString().replace("T", " ").dropLast(5)
369-
}
374+
+formatTimestampForDisplay(log.occurred_at, userTimezone)
370375
}
371376
td("hidden py-5 pl-8 pr-0 align-top text-gray-700 sm:table-cell") { log.user?.let { +it } }
372377
td("hidden py-5 pl-8 pr-0 align-top text-gray-700 sm:table-cell") { log.partition_name?.let { +it } }
@@ -533,7 +538,7 @@ class BackfillShowAction @Inject constructor(
533538
}
534539
}
535540

536-
private fun TagConsumer<*>.ConfigurationRows(id: Long, it: DescriptionListRow, backfill: GetBackfillStatusResponse) {
541+
private fun TagConsumer<*>.ConfigurationRows(id: Long, it: DescriptionListRow, backfill: GetBackfillStatusResponse, userTimezone: ZoneId?) {
537542
div("px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0") {
538543
attributes["data-controller"] = "toggle"
539544

@@ -545,11 +550,7 @@ class BackfillShowAction @Inject constructor(
545550

546551
// Special handling for "Created" field to add timestamp formatting
547552
if (it.label == "Created") {
548-
span {
549-
attributes["data-timestamp"] = backfill.created_at.toString()
550-
attributes["class"] = "localized-time"
551-
+backfill.created_at.toString().replace("T", " ").dropLast(5)
552-
}
553+
+formatTimestampForDisplay(backfill.created_at, userTimezone)
553554
+" ${it.description}"
554555
} else {
555556
+it.description
@@ -729,6 +730,22 @@ class BackfillShowAction @Inject constructor(
729730
}
730731
}
731732

733+
private fun formatTimestampForDisplay(timestamp: Any, userTimezone: ZoneId?): String {
734+
return try {
735+
if (userTimezone != null) {
736+
val instant = Instant.parse(timestamp.toString())
737+
val zonedDateTime = instant.atZone(userTimezone)
738+
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z")
739+
zonedDateTime.format(formatter)
740+
} else {
741+
timestamp.toString().replace("T", " ").dropLast(5)
742+
}
743+
} catch (e: Exception) {
744+
// Fall back to original format if parsing fails
745+
timestamp.toString().replace("T", " ").dropLast(5)
746+
}
747+
}
748+
732749
private fun formatEta(etaMillis: Double): String {
733750
val durationSeconds = etaMillis / 1000
734751
var temp = durationSeconds.toLong()
@@ -766,60 +783,42 @@ class BackfillShowAction @Inject constructor(
766783
return if (sb.isEmpty()) "< 1s" else sb.toString()
767784
}
768785

769-
private fun formatToLocalTimestampsScript(): String = """
770-
function formatTimestamps() {
771-
document.querySelectorAll('.localized-time').forEach(function(element) {
772-
const timestamp = element.getAttribute('data-timestamp');
773-
if (timestamp) {
774-
try {
775-
const date = new Date(timestamp);
776-
777-
if (!isNaN(date.getTime())) {
778-
let formatted;
779-
try {
780-
formatted = date.toLocaleString('en-CA', {
781-
timeZoneName: 'short',
782-
year: 'numeric',
783-
month: '2-digit',
784-
day: '2-digit',
785-
hour: '2-digit',
786-
minute: '2-digit',
787-
second: '2-digit',
788-
hour12: true
789-
});
790-
} catch (e1) {
791-
// Fallback to default locale
792-
formatted = date.toLocaleString(undefined, {
793-
timeZoneName: 'short',
794-
year: 'numeric',
795-
month: '2-digit',
796-
day: '2-digit',
797-
hour: '2-digit',
798-
minute: '2-digit',
799-
second: '2-digit',
800-
hour12: true
801-
});
802-
}
786+
private fun timezoneDetectionScript(): String = """
787+
(function() {
788+
try {
789+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
790+
document.cookie = 'user_timezone=' + encodeURIComponent(timezone) + '; path=/; max-age=31536000';
791+
} catch (e) {
792+
// Silently fail
793+
}
794+
})();
795+
""".trimIndent()
803796

804-
formatted = formatted.replace(',', '').replace(/\.m\./g, 'm');
805-
element.textContent = formatted;
806-
}
807-
} catch (e) {
808-
console.error('Failed to format timestamp:', timestamp, e);
809-
}
797+
private fun formatNumber(number: Long): String {
798+
return number.toString().replace(Regex("(\\d)(?=(\\d{3})+(?!\\d))"), "$1,")
799+
}
800+
801+
private fun getUserTimezone(): ZoneId? {
802+
return try {
803+
val request = httpCall.get().asOkHttpRequest()
804+
val cookieHeader = request.header("Cookie")
805+
806+
cookieHeader?.let { cookies ->
807+
val userTimezoneCookie = cookies.split(";")
808+
.map { it.trim() }
809+
.find { it.startsWith("user_timezone=") }
810+
?.substringAfter("user_timezone=")
811+
?.let { URLDecoder.decode(it, "UTF-8") }
812+
813+
userTimezoneCookie?.let { timezoneString ->
814+
ZoneId.of(timezoneString)
810815
}
811-
});
816+
}
817+
} catch (e: Exception) {
818+
// Fall back to UTC if there's any issue
819+
null
812820
}
813-
814-
formatTimestamps();
815-
816-
document.addEventListener('DOMContentLoaded', formatTimestamps);
817-
document.addEventListener('turbo:frame-load', formatTimestamps);
818-
setTimeout(formatTimestamps, 100);
819-
820-
// Run periodically to catch any missed updates
821-
setInterval(formatTimestamps, 1000);
822-
""".trimIndent()
821+
}
823822

824823
companion object {
825824
private const val PATH = "/backfills/{id}"

0 commit comments

Comments
 (0)