Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog/unreleased/checkin-all-timezone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### English

- Check in all no longer fails silently when the reported server timezone is not a name Intl accepts. An invalid zone falls back to the browser zone, and a failure shows an error instead of leaving the button idle.
- The sidebar in-flight count no longer polls `/api/live` on every page. That poll stays on the overview, which is the page that renders it.

### 中文

- 一键签到不再因为上报的服务器时区不是 Intl 接受的名字而静默失败。非法时区会退回浏览器时区,失败时会显示错误,而不是按钮毫无反应。
- 侧栏的并发数不再在每个页面轮询 `/api/live`。这个轮询只留在真正渲染它的概览页。
41 changes: 5 additions & 36 deletions frontend/src/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { NavLink } from 'react-router-dom'
import { Drawer, Button, Chip, Separator, Tooltip } from '@heroui/react'
import { ChartBar, Cube, Gauge, GearSix, Key, Lightning, Scroll, SidebarSimple, UsersThree, X } from '@phosphor-icons/react'
Expand All @@ -8,7 +8,6 @@ import { SkeletonBlock } from '@/components/ui/PageSkeletons'
import { pressScale } from '@/hooks/useGsapReveal'
import { useI18n } from '@/hooks/useI18n'
import { useOverview } from '@/hooks/useOverview'
import { fetchLive } from '@/api/live'

const nav = [
{ to: '/', key: 'navOverview', icon: Gauge, section: 'workspace', end: true },
Expand Down Expand Up @@ -114,44 +113,14 @@ function SidebarStatusSkeleton({ label }: { label: string }) {
)
}

const IN_FLIGHT_POLL_MS = 2500

/**
* Live in-flight request count from /api/live (same source as the overview
* strip). Polls every 2.5s only while the tab is visible; the dot is muted
* at 0 and pulses green while requests are running.
* In-flight count shown in the sidebar. The number comes from the overview
* summary, which loads with the shell. Polling /api/live here would hit it on
* every page, including ones that never render the live strip.
*/
function InFlightIndicator({ initial, collapsed }: { initial: number; collapsed: boolean }) {
const { t } = useI18n()
const [count, setCount] = useState<number | null>(null)
const value = count ?? initial

useEffect(() => {
let cancelled = false
let timer: number | undefined
const poll = () => {
void fetchLive()
.then((data) => { if (!cancelled) setCount(data.in_flight ?? 0) })
.catch(() => { /* keep last value */ })
}
const start = () => {
if (timer != null || document.hidden) return
poll()
timer = window.setInterval(poll, IN_FLIGHT_POLL_MS)
}
const stop = () => {
if (timer != null) window.clearInterval(timer)
timer = undefined
}
const onVisibility = () => (document.hidden ? stop() : start())
start()
document.addEventListener('visibilitychange', onVisibility)
return () => {
cancelled = true
stop()
document.removeEventListener('visibilitychange', onVisibility)
}
}, [])
const value = initial

const label = t('sidebarInFlight', { count: value })
return (
Expand Down
162 changes: 92 additions & 70 deletions frontend/src/pages/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,15 @@ export function AccountsPage() {

function checkinTimezoneFor(account: AccountRow) {
const timezone = checkinPolicyFor(account)?.timezone
return timezone === 'Local' ? settings?.timezone : timezone
const resolved = timezone === 'Local' ? settings?.timezone : timezone
if (!resolved || resolved === 'Local') return undefined
// Settings report the zone as "MST -07:00", which Intl rejects.
try {
Intl.DateTimeFormat('en-CA', { timeZone: resolved })
return resolved
} catch {
return undefined
}
}
const displayRows = useMemo(() => rows.map((account) => {
const enabled = enabledById[account.id]
Expand Down Expand Up @@ -424,75 +432,89 @@ export function AccountsPage() {
})
}

function alreadyCheckedInToday(account: AccountRow) {
const status = account.last_checkin_status
if ((status !== 'success' && status !== 'already') || !account.last_checkin_at) return false
const recorded = new Date(account.last_checkin_at)
if (Number.isNaN(recorded.getTime())) return false
const timezone = checkinTimezoneFor(account)
const dayOf = (value: Date) => new Intl.DateTimeFormat('en-CA', {
timeZone: timezone || undefined,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(value)
return dayOf(recorded) === dayOf(new Date())
}

async function onCheckinAll() {
const eligible = displayRows.filter((account) => account.enabled && checkinPolicyFor(account))
const pending = eligible.filter((account) => !alreadyCheckedInToday(account))
if (eligible.length === 0) {
toast({ id: 'checkin-all', type: 'info', title: t('checkinAllEmpty') })
return
}
if (pending.length === 0) {
toast({ id: 'checkin-all', type: 'success', title: t('checkinAllDone', { count: eligible.length }) })
return
}
// Show the progress before the first account responds. With many
// accounts the first result can take a while, and the toast would
// otherwise appear only after it.
toast({
id: 'checkin-all',
type: 'loading',
title: t('toastCheckingInAll', { done: 0, total: pending.length }),
})
setCheckinAllRunning(true)
const summary = { checked: 0, skipped: eligible.length - pending.length, failed: 0 }
try {
for (let index = 0; index < pending.length; index += 1) {
// One account at a time, with a pause before every account after the
// first, so a manual pass cannot burst every upstream call at once.
// Accounts already checked in today never reach here.
if (index > 0) {
await new Promise((resolve) => setTimeout(resolve, 4000))
}
try {
await checkinAccount(pending[index].id)
summary.checked += 1
} catch {
summary.failed += 1
}
if (index < pending.length - 1) {
toast({
id: 'checkin-all',
type: 'loading',
title: t('toastCheckingInAll', { done: index + 1, total: pending.length }),
})
}
}
const values = { checked: summary.checked, skipped: summary.skipped, failed: summary.failed }
toast({
id: 'checkin-all',
type: summary.failed ? 'error' : 'success',
title: t(summary.failed ? 'toastCheckedInAllFailed' : 'toastCheckedInAll', values),
})
} finally {
setCheckinAllRunning(false)
await reloadAccounts(false).catch(() => undefined)
}
}
function alreadyCheckedInToday(account: AccountRow) {
const status = account.last_checkin_status
if ((status !== 'success' && status !== 'already') || !account.last_checkin_at) return false
const recorded = new Date(account.last_checkin_at)
if (Number.isNaN(recorded.getTime())) return false
const timezone = checkinTimezoneFor(account)
const dayOf = (value: Date) => new Intl.DateTimeFormat('en-CA', {
timeZone: timezone || undefined,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(value)
return dayOf(recorded) === dayOf(new Date())
}

async function onCheckinAll() {
let eligible: AccountRow[]
let pending: AccountRow[]
try {
eligible = displayRows.filter((account) => account.enabled && checkinPolicyFor(account))
pending = eligible.filter((account) => !alreadyCheckedInToday(account))
} catch (error) {
// A bad check-in timezone must not swallow the click. The button
// otherwise stays idle and the page shows nothing.
toast({
id: 'checkin-all',
type: 'error',
title: t('toastFailed'),
description: error instanceof Error ? error.message : String(error),
})
return
}
if (eligible.length === 0) {
toast({ id: 'checkin-all', type: 'info', title: t('checkinAllEmpty') })
return
}
if (pending.length === 0) {
toast({ id: 'checkin-all', type: 'success', title: t('checkinAllDone', { count: eligible.length }) })
return
}
// Show the progress before the first account responds. With many
// accounts the first result can take a while, and the toast would
// otherwise appear only after it.
toast({
id: 'checkin-all',
type: 'loading',
title: t('toastCheckingInAll', { done: 0, total: pending.length }),
})
setCheckinAllRunning(true)
const summary = { checked: 0, skipped: eligible.length - pending.length, failed: 0 }
try {
for (let index = 0; index < pending.length; index += 1) {
// One account at a time, with a pause before every account after the
// first, so a manual pass cannot burst every upstream call at once.
// Accounts already checked in today never reach here.
if (index > 0) {
await new Promise((resolve) => setTimeout(resolve, 4000))
}
try {
await checkinAccount(pending[index].id)
summary.checked += 1
} catch {
summary.failed += 1
}
if (index < pending.length - 1) {
toast({
id: 'checkin-all',
type: 'loading',
title: t('toastCheckingInAll', { done: index + 1, total: pending.length }),
})
}
}
const values = { checked: summary.checked, skipped: summary.skipped, failed: summary.failed }
toast({
id: 'checkin-all',
type: summary.failed ? 'error' : 'success',
title: t(summary.failed ? 'toastCheckedInAllFailed' : 'toastCheckedInAll', values),
})
} finally {
setCheckinAllRunning(false)
await reloadAccounts(false).catch(() => undefined)
}
}

async function onCheckin(id: string) {
await run(id, 'checkin', async () => {
Expand Down
Loading
Loading