From 09cd262c7cf1e3f2da96a8667c17cd8c14bb3dce Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Thu, 29 Oct 2026 16:38:27 +0100 Subject: [PATCH] feat(web): show the time report on the Time page The time service's report reaches the app through the gateway as GET /api/time/report, but nothing showed it: the Time tab still opened the placeholder page. The Time page now shows this week's hours on the Home's week card and the last 7 days, a row each, with the weekdays that miss their hours marked, so a missing day stands out before the week ends. Decision 184 records the choices. --- README.md | 2 +- apps/web/e2e/demo.ts | 16 ++++ apps/web/e2e/time.spec.ts | 133 +++++++++++++++++++++++++++++ apps/web/src/App.pages.test.ts | 62 ++++++++++++++ apps/web/src/App.svelte | 3 + apps/web/src/App.test.ts | 28 ------ apps/web/src/shell/page.test.ts | 6 +- apps/web/src/shell/page.ts | 11 ++- apps/web/src/time/DayList.svelte | 41 +++++++++ apps/web/src/time/TimePage.svelte | 72 ++++++++++++++++ apps/web/src/time/TimePage.test.ts | 105 +++++++++++++++++++++++ apps/web/src/time/days.test.ts | 54 ++++++++++++ apps/web/src/time/days.ts | 43 ++++++++++ apps/web/src/time/report.ts | 27 ++++++ apps/web/src/time/week.ts | 3 +- docs/plan.md | 14 ++- docs/setup.md | 2 +- 17 files changed, 586 insertions(+), 36 deletions(-) create mode 100644 apps/web/e2e/time.spec.ts create mode 100644 apps/web/src/App.pages.test.ts create mode 100644 apps/web/src/time/DayList.svelte create mode 100644 apps/web/src/time/TimePage.svelte create mode 100644 apps/web/src/time/TimePage.test.ts create mode 100644 apps/web/src/time/days.test.ts create mode 100644 apps/web/src/time/days.ts create mode 100644 apps/web/src/time/report.ts diff --git a/README.md b/README.md index 223b1a0..af4fc77 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A personal dashboard for one user, hosted at home: Obsidian notes, work todos and pull requests, GitHub, games, and a Copilot planner, in one place. It's reached over Tailscale and needs a Google sign-in. It works on a Mac and on an iPhone, where it installs as a Home Screen app. -It's early. Today Bakhyard has its app shell, the sign-in, read-only notes, todos linked to Jira, open pull requests from Bitbucket with where each stands with its reviewers, the pull requests that wait for the user's review, and a Home that gathers what needs the user from the todos and the pull requests. The modules come one small step at a time, and [the plan](docs/plan.md) has the steps, their order and the decisions behind them. It runs at home on an always-on Linux laptop with k3s, deployed from a private homelab repo, and on a Mac for development. +It's early. Today Bakhyard has its app shell, the sign-in, read-only notes, todos linked to Jira, open pull requests from Bitbucket with where each stands with its reviewers, the pull requests that wait for the user's review, the hours logged on Jira over the last 7 days, and a Home that gathers what needs the user from the todos and the pull requests. The modules come one small step at a time, and [the plan](docs/plan.md) has the steps, their order and the decisions behind them. It runs at home on an always-on Linux laptop with k3s, deployed from a private homelab repo, and on a Mac for development. ## How it's built diff --git a/apps/web/e2e/demo.ts b/apps/web/e2e/demo.ts index 2e3f86b..67a2a3c 100644 --- a/apps/web/e2e/demo.ts +++ b/apps/web/e2e/demo.ts @@ -1,6 +1,7 @@ import type { Home } from '../src/home/api'; import type { State } from '../src/nudge/nudge'; import type { Snapshot as Pulls } from '../src/pulls/pulls'; +import type { Report } from '../src/time/report'; import type { Todo } from '../src/todos/api'; import type { Snapshot } from '../src/todos/tickets'; @@ -156,3 +157,18 @@ export const nudge: State = { { id: 's2', project: 'notes (home)', note: 'Drafting the review template', noted_at: '2026-10-21T00:36:00Z' }, ], }; + +/** A made-up user's time report at the tests' time: the last 7 days, with nothing logged on the Friday or the weekend. */ +export const report: Report = { + days: [ + { date: '2026-10-15', loggedHours: 7.5, missing: false }, + { date: '2026-10-16', loggedHours: 0, missing: true }, + { date: '2026-10-17', loggedHours: 0, missing: false }, + { date: '2026-10-18', loggedHours: 0, missing: false }, + { date: '2026-10-19', loggedHours: 8, missing: false }, + { date: '2026-10-20', loggedHours: 6.25, missing: false }, + { date: '2026-10-21', loggedHours: 1.5, missing: false }, + ], + week: { loggedHours: 15.75, targetHours: 40 }, + searched: '2026-10-21T00:38:00Z', +}; diff --git a/apps/web/e2e/time.spec.ts b/apps/web/e2e/time.spec.ts new file mode 100644 index 0000000..8701097 --- /dev/null +++ b/apps/web/e2e/time.spec.ts @@ -0,0 +1,133 @@ +import type { Locator, Page } from '@playwright/test'; + +import type { Report } from '../src/time/report'; +import { chipLooks } from './chips'; +import { home, report } from './demo'; +import { expect, test } from './fixtures'; + +/** Answers the Time page's call for the report with the report, or with the status. */ +async function answerReport(page: Page, answer: Report | number) { + await page.route('/api/time/report', (route) => + typeof answer === 'number' ? route.fulfill({ status: answer }) : route.fulfill({ json: answer }) + ); +} + +const heads = (page: Page) => page.getByRole('main').getByRole('heading', { level: 2 }); +const days = (page: Page) => page.getByRole('main').getByRole('listitem'); +const color = (locator: Locator) => locator.evaluate((element) => getComputedStyle(element).color); + +test.beforeEach(async ({ page }) => { + await page.route('/api/home', (route) => route.fulfill({ json: home })); +}); + +test("the Time page shows this week's hours and the last 7 days' from Jira", async ({ page }) => { + await answerReport(page, report); + await page.goto('/time'); + + await expect(page).toHaveTitle('Time · Bakhyard'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Time'); + await expect(heads(page)).toHaveText(['This week', 'Last 7 days']); + await expect(page.getByText('15h 45m / 40h')).toBeVisible(); + await expect(page.getByText('1 day missing')).toBeVisible(); + await expect(days(page)).toHaveText([ + '15 Oct Thursday 7h 30m', + '16 Oct Friday Missing 0h', + '17 Oct Saturday 0h', + '18 Oct Sunday 0h', + '19 Oct Monday 8h', + '20 Oct Tuesday 6h 15m', + '21 Oct Today 1h 30m', + ]); + // Missing reads like a late item's chip. + expect(await chipLooks(page.getByRole('main').locator('.chip'))).toEqual(['filled']); +}); + +test('the hours of a day show in the ink once logged, and stay muted like its date when not', async ({ page }) => { + await answerReport(page, report); + await page.goto('/time'); + + const hours = (day: string) => days(page).filter({ hasText: day }).locator('.list-end > :last-child'); + await expect(hours('19 Oct')).toHaveText('8h'); + expect(await color(hours('19 Oct'))).toBe(await color(page.getByText('Monday'))); + expect(await color(hours('17 Oct'))).toBe(await color(page.getByText('17 Oct'))); + expect(await color(hours('17 Oct'))).not.toBe(await color(page.getByText('Saturday'))); +}); + +test('the Time page is current in the header, or under More in the tab bar', async ({ page }, testInfo) => { + await answerReport(page, report); + await page.goto('/time'); + + // The tab bar, the phone layout, has no tab for Time. + const [name, current] = testInfo.project.name === 'phone' ? ['More', 'true'] : ['Time', 'page']; + await expect(page.getByRole('link', { name, exact: true })).toHaveAttribute('aria-current', current); +}); + +test("the Home's day report link opens the Time page, which loads the report each time it shows", async ({ page }) => { + let answer = report; + await page.route('/api/time/report', (route) => route.fulfill({ json: answer })); + await page.goto('/time'); + await expect(days(page).last()).toHaveText('21 Oct Today 1h 30m'); + + await page.getByRole('link', { name: 'Home', exact: true }).click(); + await expect(page.getByRole('heading', { level: 1, name: 'Good morning, Alex.' })).toBeVisible(); + // The time service finds an hour logged while the page is away. + answer = { + ...report, + days: report.days?.map((day) => (day.date === '2026-10-21' ? { ...day, loggedHours: 2.5 } : day)), + week: { loggedHours: 16.75, targetHours: 40 }, + }; + await page.getByRole('link', { name: 'Day report' }).click(); + + await expect(page).toHaveURL('/time'); + await expect(days(page).last()).toHaveText('21 Oct Today 2h 30m'); + await expect(page.getByText('16h 45m / 40h')).toBeVisible(); +}); + +test('the Time page says why the last search failed, and when its hours are from', async ({ page }, testInfo) => { + await answerReport(page, { ...report, failure: 'search my worklogs: 502 Bad Gateway' }); + await page.goto('/time'); + + const line = page.getByRole('main').getByText(/^The last search failed/); + await expect(line).toHaveText( + 'The last search failed: search my worklogs: 502 Bad Gateway. These hours are from 21 October at 09:38.' + ); + await expect(line.locator('time')).toHaveAttribute('datetime', '2026-10-21T00:38:00Z'); + await expect(heads(page)).toHaveText(['This week', 'Last 7 days']); + // Over the sections, the line leaves the space that the Todos page's form leaves over its first section. + await expect(line).toHaveCSS('padding-bottom', testInfo.project.name === 'phone' ? '24px' : '30px'); +}); + +test('before a search succeeds, the Time page says why it failed, with no section', async ({ page }, testInfo) => { + await answerReport(page, { failure: 'find me: 401 Unauthorized' }); + await page.goto('/time'); + + const line = page.getByRole('main').getByText(/^The last search failed/); + await expect(line).toHaveText('The last search failed: find me: 401 Unauthorized.'); + await expect(line).toHaveCSS('padding-bottom', testInfo.project.name === 'phone' ? '48px' : '64px'); + await expect(heads(page)).toHaveCount(0); +}); + +for (const { name, status, says } of [ + { name: "Jira isn't set up", status: 404, says: "Jira isn't set up." }, + { name: "the report couldn't load", status: 502, says: "Couldn't load the time report." }, +]) { + test(`the Time page says when ${name}, with no section`, async ({ page }) => { + await answerReport(page, status); + await page.goto('/time'); + + await expect(page.getByRole('main').getByText(says)).toBeVisible(); + await expect(heads(page)).toHaveCount(0); + }); +} + +test('a long failure wraps on a narrow screen', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 640 }); + const failure = `search my worklogs: Get "https://api.atlassian.com/ex/jira/${'z'.repeat(200)}": EOF`; + await answerReport(page, { ...report, failure }); + await page.goto('/time'); + + await expect(page.getByRole('main').getByText(/^The last search failed/)).toContainText(failure); + await expect(days(page)).toHaveCount(7); + const scroll = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(scroll).toBe(0); +}); diff --git a/apps/web/src/App.pages.test.ts b/apps/web/src/App.pages.test.ts new file mode 100644 index 0000000..d74ffec --- /dev/null +++ b/apps/web/src/App.pages.test.ts @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import App from './App.svelte'; +import type { Home } from './home/api'; + +const home: Home = { name: 'Ada', items: [] }; +const searched = '2026-10-20T00:41:00Z'; + +// The answer for each request that an app's page makes; the Home's for any other. +function answer(url: string): Response { + switch (url) { + case '/api/todos': + return Response.json({ todos: [{ id: 1, title: 'Buy milk' }] }); + case '/api/pulls/mine': + case '/api/pulls/reviews': + return Response.json({ pulls: [], searched }); + case '/api/time/report': + return Response.json({ + days: [{ date: '2026-10-20', loggedHours: 7.5, missing: false }], + week: { loggedHours: 7.5, targetHours: 40 }, + searched, + }); + default: + return Response.json(home); + } +} + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url) => answer(String(url))) + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + history.replaceState(null, '', '/'); +}); + +test('shows the page at the path it opens on', () => { + history.replaceState(null, '', '/games'); + render(App); + + expect(screen.getByRole('heading', { level: 1, name: 'Games' })).toBeInTheDocument(); + expect(screen.getByText('Coming soon')).toBeInTheDocument(); + expect(document.title).toBe('Games · Bakhyard'); + expect(screen.getByRole('link', { name: 'Games', current: 'page' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'More', current: true })).toBeInTheDocument(); +}); + +test.each([ + ['/todos', '1 todo on the list.', 'Todos · Bakhyard'], + ['/pulls', 'No open pull requests.', 'Pull requests · Bakhyard'], + ['/time', '7h 30m / 40h', 'Time · Bakhyard'], +])('shows the page of the app at %s, with what it loads', async (path, loaded, title) => { + history.replaceState(null, '', path); + render(App); + + expect(await screen.findByText(loaded)).toBeInTheDocument(); + expect(document.title).toBe(title); +}); diff --git a/apps/web/src/App.svelte b/apps/web/src/App.svelte index e101bfd..584707b 100644 --- a/apps/web/src/App.svelte +++ b/apps/web/src/App.svelte @@ -19,6 +19,7 @@ import { followNavigation } from './shell/navigation'; import { pageAt, pageTitle, signedOutTitle } from './shell/page'; import TabBar from './shell/TabBar.svelte'; import TopBar from './shell/TopBar.svelte'; +import TimePage from './time/TimePage.svelte'; import TodosPage from './todos/TodosPage.svelte'; let path = $state(location.pathname); @@ -73,6 +74,8 @@ $effect(() => {:else if page.kind === 'pulls'} + {:else if page.kind === 'time'} + {:else if page.kind === 'nudge'} {:else if page.kind === 'app'} diff --git a/apps/web/src/App.test.ts b/apps/web/src/App.test.ts index 0eea004..c097019 100644 --- a/apps/web/src/App.test.ts +++ b/apps/web/src/App.test.ts @@ -59,12 +59,6 @@ function answer(url: string): Response { if (url === '/api/notes/files/Work/Plan.md') { return new Response('# Plan\n\n## Goals\nShip it.'); } - if (url === '/api/todos') { - return Response.json({ todos: [{ id: 1, title: 'Buy milk' }] }); - } - if (url === '/api/pulls/mine' || url === '/api/pulls/reviews') { - return Response.json({ pulls: [], searched: '2026-10-20T00:41:00Z' }); - } return Response.json(home); } @@ -104,17 +98,6 @@ test('shows the frame and the apps, then the hero, the feed, the counts, the wee expect(document.title).toBe('Bakhyard'); }); -test('shows the page at the path it opens on', () => { - history.replaceState(null, '', '/time'); - render(App); - - expect(screen.getByRole('heading', { level: 1, name: 'Time' })).toBeInTheDocument(); - expect(screen.getByText('Coming soon')).toBeInTheDocument(); - expect(document.title).toBe('Time · Bakhyard'); - expect(screen.getByRole('link', { name: 'Time', current: 'page' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'More', current: true })).toBeInTheDocument(); -}); - test('shows each page in place as the browser navigates, with the Home it has already loaded', async () => { const navigation = new EventTarget(); vi.stubGlobal('navigation', navigation); @@ -172,17 +155,6 @@ test('opens a note, and keeps the Notes page as the user left it while the app r expect(vi.mocked(fetch).mock.calls.filter(([url]) => url === '/api/notes')).toHaveLength(1); }); -test.each([ - ['/todos', '1 todo on the list.', 'Todos · Bakhyard'], - ['/pulls', 'No open pull requests.', 'Pull requests · Bakhyard'], -])('shows the page of the app at %s, with what it loads', async (path, loaded, title) => { - history.replaceState(null, '', path); - render(App); - - expect(await screen.findByText(loaded)).toBeInTheDocument(); - expect(document.title).toBe(title); -}); - test('leaves navigations to the browser once it goes away', () => { const navigation = new EventTarget(); vi.stubGlobal('navigation', navigation); diff --git a/apps/web/src/shell/page.test.ts b/apps/web/src/shell/page.test.ts index 08e7883..e2051f5 100644 --- a/apps/web/src/shell/page.test.ts +++ b/apps/web/src/shell/page.test.ts @@ -33,7 +33,10 @@ test.each([ { path: '/pulls/', want: 'pulls' }, { path: '/pulls/42', want: 'pulls' }, { path: '/pulls-archive', want: 'missing' }, - { path: '/time', want: 'app time' }, + { path: '/time', want: 'time' }, + { path: '/time/', want: 'time' }, + { path: '/time/2026-10-21', want: 'time' }, + { path: '/time-archive', want: 'missing' }, { path: '/github', want: 'app github' }, { path: '/games/zelda', want: 'app games' }, { path: '/planner', want: 'app planner' }, @@ -56,6 +59,7 @@ test.each([ { page: pageAt('/notes/Daily/To%20do'), want: 'To do · Bakhyard' }, { page: pageAt('/todos'), want: 'Todos · Bakhyard' }, { page: pageAt('/time'), want: 'Time · Bakhyard' }, + { page: pageAt('/games'), want: 'Games · Bakhyard' }, { page: pageAt('/pulls/42'), want: 'Pull requests · Bakhyard' }, { page: pageAt('/nudge'), want: 'Nudge · Bakhyard' }, { page: pageAt('/more'), want: 'More · Bakhyard' }, diff --git a/apps/web/src/shell/page.ts b/apps/web/src/shell/page.ts index 1443c8c..b3e3ae8 100644 --- a/apps/web/src/shell/page.ts +++ b/apps/web/src/shell/page.ts @@ -9,6 +9,7 @@ export type Page = | { kind: 'note'; note: Note } | { kind: 'todos' } | { kind: 'pulls' } + | { kind: 'time' } | { kind: 'nudge' } | { kind: 'app'; app: App } | { kind: 'more' } @@ -17,12 +18,13 @@ export type Page = const notesHref = appHref('notes'); const todosHref = appHref('todos'); const pullsHref = appHref('pulls'); +const timeHref = appHref('time'); const nudgeHref = appHref('nudge'); /** * The page at path. The Notes app's path shows the notes, and the paths under it their notes; the Todos app's paths - * show the todos, the PRs app's my PRs, and the Nudge app's the agents' questions; another app's paths show its page, - * as the nav marks it. + * show the todos, the PRs app's my PRs, the Time app's the time report, and the Nudge app's the agents' questions; + * another app's paths show its page, as the nav marks it. */ export function pageAt(path: string): Page { if (path === '/') { @@ -40,6 +42,9 @@ export function pageAt(path: string): Page { if (isCurrent(pullsHref, path)) { return { kind: 'pulls' }; } + if (isCurrent(timeHref, path)) { + return { kind: 'time' }; + } if (isCurrent(nudgeHref, path)) { return { kind: 'nudge' }; } @@ -74,6 +79,8 @@ export function pageTitle(page: Page): string { return `Todos · ${siteName}`; case 'pulls': return `Pull requests · ${siteName}`; + case 'time': + return `Time · ${siteName}`; case 'nudge': return `Nudge · ${siteName}`; case 'app': diff --git a/apps/web/src/time/DayList.svelte b/apps/web/src/time/DayList.svelte new file mode 100644 index 0000000..d1e5f9d --- /dev/null +++ b/apps/web/src/time/DayList.svelte @@ -0,0 +1,41 @@ + + +
+
    + {#each dayRows(days) as row (row.date)} +
  • +
    + + + {row.title} + + + {#if row.missing} + Missing + {/if} + {row.hours} + +
    +
  • + {/each} +
+
+ + diff --git a/apps/web/src/time/TimePage.svelte b/apps/web/src/time/TimePage.svelte new file mode 100644 index 0000000..497ebd3 --- /dev/null +++ b/apps/web/src/time/TimePage.svelte @@ -0,0 +1,72 @@ + + +Time +{#if report.kind === 'off'} +

Jira isn't set up.

+{:else if report.kind === 'failed'} +

Couldn't load the time report.

+{:else if report.kind === 'loaded'} + {@const { days, week, searched, failure } = report.snapshot} + {#if failure} +

+ The last search failed: {failure}. + {#if days && searched} + These hours are from . + {/if} +

+ {/if} + {#if week} +
+ +
+ {/if} + {#if days} +
+ +
+ {/if} +{/if} + + diff --git a/apps/web/src/time/TimePage.test.ts b/apps/web/src/time/TimePage.test.ts new file mode 100644 index 0000000..6cababe --- /dev/null +++ b/apps/web/src/time/TimePage.test.ts @@ -0,0 +1,105 @@ +import { render, screen, within } from '@testing-library/svelte'; +import { afterEach, expect, test, vi } from 'vitest'; + +import type { Report } from './report'; +import TimePage from './TimePage.svelte'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function answer(response: () => Promise) { + const fetchMock = vi.fn(response); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +const searched = '2026-10-21T00:38:00Z'; +const report: Report = { + days: [ + { date: '2026-10-19', loggedHours: 8, missing: false }, + { date: '2026-10-20', loggedHours: 0, missing: true }, + { date: '2026-10-21', loggedHours: 1.5, missing: false }, + ], + week: { loggedHours: 9.5, targetHours: 40 }, + searched, +}; + +const loaded = (snapshot: Report) => async () => Response.json(snapshot); +const loading = () => Promise.withResolvers().promise; + +const heads = () => screen.queryAllByRole('heading', { level: 2 }).map((head) => head.textContent); +const rows = () => screen.queryAllByRole('listitem').map((row) => row.textContent?.replaceAll(/\s+/g, ' ').trim()); + +test('loads the time report as it shows, with this week and the last 7 days', async () => { + const fetchMock = answer(loaded(report)); + render(TimePage); + + expect(screen.getByRole('heading', { level: 1, name: 'Time' })).toBeInTheDocument(); + expect(await screen.findByText('9h 30m / 40h')).toBeInTheDocument(); + expect(heads()).toEqual(['This week', 'Last 7 days']); + expect(fetchMock.mock.calls).toEqual([['/api/time/report']]); +}); + +test('lists each day with its date, weekday and hours, and marks the missing one', async () => { + answer(loaded(report)); + render(TimePage); + + expect(await screen.findByText('1 day missing')).toBeInTheDocument(); + expect(rows()).toEqual(['19 Oct Monday 8h', '20 Oct Tuesday Missing 0h', '21 Oct Today 1h 30m']); + expect(screen.getByText('20 Oct')).toHaveAttribute('datetime', '2026-10-20'); + expect(screen.getByText('Missing')).toHaveClass('chip', 'chip-filled'); + const hours = within(screen.getByRole('list')).getAllByText(/^\d+h/); + expect(hours.map((day) => day.classList.contains('logged'))).toEqual([true, false, true]); +}); + +test('notes no missing day when none is', async () => { + answer(loaded({ ...report, days: report.days?.map((day) => ({ ...day, missing: false })) })); + render(TimePage); + + expect(await screen.findByText('20 Oct')).toBeInTheDocument(); + expect(screen.queryByText(/missing/i)).not.toBeInTheDocument(); +}); + +test('shows nothing under its head while the report loads', () => { + answer(loading); + render(TimePage); + + expect(screen.getByRole('heading', { level: 1, name: 'Time' })).toBeInTheDocument(); + expect(heads()).toEqual([]); + expect(screen.queryByRole('paragraph')).not.toBeInTheDocument(); +}); + +test.each([ + { name: "Jira isn't set up", status: 404, says: "Jira isn't set up." }, + { name: "the report couldn't load", status: 502, says: "Couldn't load the time report." }, +])('says so, with no section, when $name', async ({ status, says }) => { + answer(async () => new Response('No\n', { status })); + render(TimePage); + + expect(await screen.findByText(says)).toHaveClass('off'); + expect(heads()).toEqual([]); +}); + +test('says why the last search failed, and when the hours it shows are from', async () => { + answer(loaded({ ...report, failure: 'search worklogs: 502 Bad Gateway' })); + render(TimePage); + + const line = await screen.findByText(/^The last search failed/); + expect(line).toHaveTextContent( + 'The last search failed: search worklogs: 502 Bad Gateway. These hours are from 21 October at 09:38.' + ); + expect(line).toHaveClass('failure'); + expect(within(line).getByText('21 October at 09:38')).toHaveAttribute('datetime', searched); + expect(heads()).toEqual(['This week', 'Last 7 days']); +}); + +test('says why the last search failed, with no section, before any search has succeeded', async () => { + answer(loaded({ failure: 'find me: 401 Unauthorized' })); + render(TimePage); + + const line = await screen.findByText(/^The last search failed/); + expect(line).toHaveTextContent(/^The last search failed: find me: 401 Unauthorized\.$/); + expect(line).toHaveClass('off'); + expect(heads()).toEqual([]); +}); diff --git a/apps/web/src/time/days.test.ts b/apps/web/src/time/days.test.ts new file mode 100644 index 0000000..bf213b3 --- /dev/null +++ b/apps/web/src/time/days.test.ts @@ -0,0 +1,54 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +import { dayRows, missingNote } from './days'; +import type { Day } from './report'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +// Thursday 15 to Wednesday 21 October 2026, with nothing logged on the Friday or over the weekend. +const days: Day[] = [ + { date: '2026-10-15', loggedHours: 7.5, missing: false }, + { date: '2026-10-16', loggedHours: 0, missing: true }, + { date: '2026-10-17', loggedHours: 0, missing: false }, + { date: '2026-10-18', loggedHours: 0, missing: false }, + { date: '2026-10-19', loggedHours: 8, missing: false }, + { date: '2026-10-20', loggedHours: 6.25, missing: false }, + { date: '2026-10-21', loggedHours: 1.5, missing: false }, +]; + +test("lists the days in the report's order by their dates and weekdays, the last one as today", () => { + expect(dayRows(days)).toEqual([ + { date: '2026-10-15', label: '15 Oct', title: 'Thursday', hours: '7h 30m', logged: true, missing: false }, + { date: '2026-10-16', label: '16 Oct', title: 'Friday', hours: '0h', logged: false, missing: true }, + { date: '2026-10-17', label: '17 Oct', title: 'Saturday', hours: '0h', logged: false, missing: false }, + { date: '2026-10-18', label: '18 Oct', title: 'Sunday', hours: '0h', logged: false, missing: false }, + { date: '2026-10-19', label: '19 Oct', title: 'Monday', hours: '8h', logged: true, missing: false }, + { date: '2026-10-20', label: '20 Oct', title: 'Tuesday', hours: '6h 15m', logged: true, missing: false }, + { date: '2026-10-21', label: '21 Oct', title: 'Today', hours: '1h 30m', logged: true, missing: false }, + ]); +}); + +test('names each day as the report does in a time zone behind UTC too', () => { + vi.stubEnv('TZ', 'America/Los_Angeles'); + + expect(dayRows(days.slice(0, 2)).map(({ label, title }) => `${label} ${title}`)).toEqual([ + '15 Oct Thursday', + '16 Oct Today', + ]); +}); + +test('lists no row for no day', () => { + expect(dayRows([])).toEqual([]); +}); + +test.each([ + { count: 0, want: undefined }, + { count: 1, want: '1 day missing' }, + { count: 3, want: '3 days missing' }, +])('notes $count missing days as "$want"', ({ count, want }) => { + const marked = days.map((day, index) => ({ ...day, missing: index < count })); + + expect(missingNote(marked)).toBe(want); +}); diff --git a/apps/web/src/time/days.ts b/apps/web/src/time/days.ts new file mode 100644 index 0000000..803e372 --- /dev/null +++ b/apps/web/src/time/days.ts @@ -0,0 +1,43 @@ +import type { Day } from './report'; +import { formatHours } from './week'; + +/** A day as the Time page lists it. */ +export interface DayRow { + /** The day, as YYYY-MM-DD. */ + date: string; + /** Its date: "15 Oct". */ + label: string; + /** Its weekday, "Thursday", or "Today". */ + title: string; + /** The hours logged on it: "7h 30m", "0h". */ + hours: string; + /** Whether any time is logged on it. */ + logged: boolean; + missing: boolean; +} + +// A report's date names a day, not a moment: read and written in UTC, it stays that day in every time zone. +function format(date: string, options: Intl.DateTimeFormatOptions): string { + return new Date(date).toLocaleDateString('en-GB', { ...options, timeZone: 'UTC' }); +} + +/** The report's days as the Time page lists them, in the report's order, whose last day is today (decision 181). */ +export function dayRows(days: readonly Day[]): DayRow[] { + return days.map((day, index) => ({ + date: day.date, + label: format(day.date, { day: 'numeric', month: 'short' }), + title: index === days.length - 1 ? 'Today' : format(day.date, { weekday: 'long' }), + hours: formatHours(day.loggedHours), + logged: day.loggedHours > 0, + missing: day.missing, + })); +} + +/** How many of the days miss their hours: "1 day missing" or "2 days missing"; none when none does. */ +export function missingNote(days: readonly Day[]): string | undefined { + const count = days.filter((day) => day.missing).length; + if (count === 0) { + return undefined; + } + return count === 1 ? '1 day missing' : `${count} days missing`; +} diff --git a/apps/web/src/time/report.ts b/apps/web/src/time/report.ts new file mode 100644 index 0000000..12d0774 --- /dev/null +++ b/apps/web/src/time/report.ts @@ -0,0 +1,27 @@ +import { loadSnapshot, type Polled, type Polls } from '../shell/snapshot'; +import type { Week } from './week'; + +/** A day of the time report. */ +export interface Day { + /** The day in the time service's zone, as YYYY-MM-DD. */ + date: string; + loggedHours: number; + /** Whether it's a weekday before today with no hours logged. */ + missing: boolean; +} + +/** The body of GET /api/time/report: the time service's report of the last 7 days (decisions 181 and 182). */ +export interface Report extends Polls { + /** The last 7 days, the earliest first and today last; none before the first poll that succeeded. */ + days?: Day[]; + /** This week's hours against the target; none before the first poll that succeeded. */ + week?: Week; +} + +/** The time report as the Time page has it: loading, off when Jira isn't set up, failed to load, or loaded. */ +export type TimeReport = Polled; + +/** Loads the time report; never fails, since a failed load is one of the outcomes. */ +export function loadReport(): Promise { + return loadSnapshot('/api/time/report'); +} diff --git a/apps/web/src/time/week.ts b/apps/web/src/time/week.ts index 6062ae8..d642ef1 100644 --- a/apps/web/src/time/week.ts +++ b/apps/web/src/time/week.ts @@ -10,7 +10,8 @@ const full = 100; // Narrow hours and minutes, as Jira writes them: "31h 30m", "40h". Hours always show, so zero reads "0h" rather than "". const duration = new Intl.DurationFormat('en-GB', { style: 'narrow', hoursDisplay: 'always' }); -function formatHours(hours: number): string { +/** Hours rounded to the minute, as Jira writes them: "7h 30m", "8h", "0h". */ +export function formatHours(hours: number): string { const minutes = Math.round(hours * minutesPerHour); return duration.format({ hours: Math.floor(minutes / minutesPerHour), minutes: minutes % minutesPerHour }); } diff --git a/docs/plan.md b/docs/plan.md index d592b92..fecc586 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -166,13 +166,14 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `time-service` | A new `time` service polls Jira for my worklogs of the last 7 days as it starts and every 5 minutes after, and answers the time report at `GET /time/report`, with when the latest poll to succeed began and why the latest poll failed, if it did. It reads todos' Jira settings and token: without them it answers 404, and with only some it doesn't start. `mise run up` runs it on port 8088 and the Gitea workflow builds its image; the gateway reaches it with `time-gateway`. The fourth of `time-tracking`'s twelve steps (decision 182) | Done (#150) | | `time-gateway` | The gateway forwards `/api/time` to the `time` service, behind both of its locks, so the app reaches my time report at `GET /api/time/report`. `time` is one of the gateway's peers, each of which `BAKHYARD_PEERS` must name, and `mise run up` starts the gateway once time is ready. The sixth of `time-tracking`'s twelve steps (decision 181) | Done (#151) | | `week-card` | The Home's "This week" card, the hours logged against the target over a bar, moves with its formatting and the week's type to the Time app's own folder, `src/time`, as a component of its own, `WeekCard`, ahead of `time-page`, which shows it too; nothing changes on screen or in behaviour. The seventh of `time-tracking`'s thirteen steps (decision 183) | Done (#152) | +| `time-page` | The Time page, at `/time`, shows the time report: this week's hours against the target on the Home's week card, then the last 7 days, a row each with the date, the weekday or "Today" and the hours logged, a Missing chip on each weekday before today without hours, and the missing days' count as the section's note. It loads the report each time it shows, says why the last search failed and when its hours are from, and says when Jira isn't set up or the report couldn't load. The eighth of `time-tracking`'s thirteen steps (decision 184) | Done (#153) | | `nudge-merge` | Nudge, the user's phone-questions tool for agents (`codedogapp/nudge`), moves into Bakhyard: one app, not two (decision 157). The nudge session does the work, one small PR per step, from its plan, [`bakhyard-merge.md`](https://github.com/codedogapp/nudge/blob/main/docs/bakhyard-merge.md); this session doesn't stop its own steps for it | In review | After these: the product phases (§8.2), each split into steps. **Deferred until a step needs it**: TypeScript code from the contracts and `buf breaking` (decision 158), NATS and events, OpenTelemetry, Prometheus metrics, streaming, unix sockets, a shared kit, per-service Go modules and `go.work`, the git hook, most mise tasks, and process-compose's detached mode and API server (§4.9). -**Ideas**: `sonar-issues` could page past 200 issues, or warn when it cuts them off. Longer task scripts could move into files that `check` shellchecks; today only the workflow's `run:` scripts are checked. A gate self-test could lint known-bad fixtures and expect every gate to fire, so a config typo can't switch one off silently (§10). pnpm's `savePrefix` setting could be empty, so `pnpm add` pins exact versions without `-E`. `//apps/web:check` could run `pnpm dedupe --check`, so a lockfile that a `pnpm add` by hand leaves with an older copy of a package fails too; Renovate's updates are deduped already (decision 101). The Home's components each style their own big numbers and mono labels (the app counts, the week's hours, the section head's note); shared type styles could replace the copies. The header's nav marks an app's link `aria-current="page"` on the app's deeper pages too (`/notes/x`); ARIA and GOV.UK's service navigation keep "page" for the link's own page and use "true" for a section the page is in, as the tab bar's More does (decision 52). Screen readers aren't told when a page changes in place; a polite live region that reads out the new title after each navigation could tell them, as SvelteKit's route announcer does. Each service's port is written twice in `process-compose.yaml`, in its `BAKHYARD_LISTEN` and its probe, and the gateway's `BAKHYARD_PEERS` repeats the other services'; a check could keep them in step. Nothing checks that every address is on the loopback, though nothing should listen on the LAN (§3). Pushover could join Web Push as a second notification channel, for alerts that repeat until acknowledged (§2, decision 64). After signing out, Back may bring back the signed-in page from the browser's back-forward cache, data included; the app could check its session again when a page is restored (`pageshow` with `persisted`). The web app's dev server (`//apps/web:dev`) proxies `/api` to the gateway on the loopback, which has answered 403 there since `auth-tailscale` (decision 77), so it shows the app without data; local development needs a way through the lock. The Notes filter matches the notes' paths only; a full-text search could look inside them too. A note with a dot in its name gets the web service's 404 when its page is reloaded (decision 94); the service could serve the app for every missing path under `/notes/`. Back and Forward can't put a note's page where it was left: the browser restores the scroll position once the page shows, before the note has loaded; the page could restore it itself once the note is in (the Navigation API's `scroll: 'manual'` and `event.scroll()`). Footnote links in a note go nowhere: rehype-sanitize prefixes the footnotes' ids but not the links to them, which GitHub fixes with a script (the vault has no footnotes yet). A task's checkbox has no accessible name, as on GitHub; it could take its item's text. These link forms wait until the vault uses them (decision 97): a note shown inline, where `![[Note]]` is a link today; heading and block links into other notes, which open the note at its top; links in a list property; links to files that aren't notes; relative paths (`../`); and links that match a note's name in another case. An image embed's size waits the same way (decision 98): `![[x.png|300]]` and `![[x.png|300x200]]` show the image at its own size today. So do embeds of other files, such as audio, video and PDFs, Markdown images of the vault's files (`![](x.png)`), and a way to see an image larger. Callouts, `> [!note]`, wait until the vault has one (decision 99). KaTeX's red for a formula that doesn't parse, `#cc0000`, has about 3.2:1 contrast on the dark paper, under WCAG AA's 4.5:1; a colour of our own could replace it. Every note's page loads KaTeX with the renderer, though one note in the vault has math; KaTeX could load only for a note that has some. The build also ships KaTeX's woff and ttf fonts, which a browser loads only when it can't read woff2, the first format KaTeX's stylesheet lists. Svelte's docs advise against the `class:` directive since 5.16, when the `class` attribute began to take an object or an array; `todos-groups` uses the attribute, while `AppCards` and the Notes `Tree` still use the directive. No test runs a migration's Down; a test could take each service's database down a migration and up again, so a Down that doesn't undo its Up fails. A todo dragged near the screen's top or bottom edge could scroll the page, so a todo on a long list can go further than the screen shows; today the user scrolls first, or uses the editor's moves (decision 120). Escape could cancel a drag. A dragged todo's row could follow the pointer while the other rows slide aside to make room, as iOS lists do, rather than a line marking the place; across groups, the cards would have to grow and shrink as it passes (decision 120). A todo could be dropped into a priority that has no todos, which has no section to drop it in; the editor's priority does that (decision 117). Three components copy the card's style, a 1 px `--rule` border on the `--surface` with the offset shadow: Home's "Needs you", the Todos page's groups and its Jira tickets; a shared class could replace the copies, as `list.css` did for the rows (decision 124). The end-to-end tests keep no trace of a failed test, so a flaky one can't be looked into afterwards; `trace: 'retain-on-failure'` would keep one. In `todos-ticket`, the Move test's five-second wait for the second move ran out twice in `mise run check`, on battery in Low Power Mode, though the move did land just after; it passed in 660 runs alone or in the whole suite. A linked todo's badge could link to its ticket in Jira; it sits in the box's label, where a tap ticks the todo, so the link needs a target of its own, at least 24 px and apart from the label (WCAG 2.2 SC 2.5.8, decision 126). The Jira inbox keeps a dismissal for good, so a dismissed ticket that leaves my open tickets and comes back later, reassigned to me or reopened, stays out of the inbox; the service could forget a dismissal once a search that succeeds no longer finds its ticket (decision 128). An inbox row could link to its ticket in Jira, as the Jira section's rows do; the row isn't a link, since it holds an Add button, so the link needs a target of its own, as the badge's does (decision 129). The Notes page's Sync could also make Gitea mirror the vault at once, through Gitea's `POST /repos/{owner}/{repo}/mirror-sync`, so a note that the Mac pushes shows on the laptop within a minute rather than up to ten (decision 151). The images workflow builds and tags all six images on every change, so Flux restarts every service each time (decision 152); it could build only the images that a change touches. GitHub's CI could build the images on each PR too, without pushing them, so a Dockerfile that no longer builds fails before the merge rather than in Gitea after it. The five Go services' Dockerfiles differ only in the service's name; one Dockerfile with the name as a build argument could replace them (decision 153). The Jira inbox's rule lives in two places, the Todos page's `inboxTickets` and todos' attention lister (decision 160); the page could take the inbox from todos through a JSON route, so the rule lives in one. The services talk in plaintext inside the cluster, gRPC over h2c and HTTP/1.1, behind the NetworkPolicies (decisions 137 and 158); mutual TLS between them, from a service mesh or from cert-manager's certificates, would encrypt that traffic. SonarQube Cloud's rule for clear-text protocols, S5332, looks for an `http://` literal, so it doesn't see these calls (decision 168). The services' connections to Postgres are encrypted but don't check the server's certificate (`sslmode=require`); with CloudNativePG's CA mounted and `sslmode=verify-full`, they would (decision 170). pulls could find my Bitbucket workspaces itself, through `GET /2.0/user/workspaces`, which needs the scope `read:workspace:bitbucket` that today's token lacks, and drop `BAKHYARD_BITBUCKET_WORKSPACES`. The time report's target is 8 hours each weekday, so a public holiday or a day off shows as a missing day (decision 180); a setting could list the days without a target. +**Ideas**: `sonar-issues` could page past 200 issues, or warn when it cuts them off. Longer task scripts could move into files that `check` shellchecks; today only the workflow's `run:` scripts are checked. A gate self-test could lint known-bad fixtures and expect every gate to fire, so a config typo can't switch one off silently (§10). pnpm's `savePrefix` setting could be empty, so `pnpm add` pins exact versions without `-E`. `//apps/web:check` could run `pnpm dedupe --check`, so a lockfile that a `pnpm add` by hand leaves with an older copy of a package fails too; Renovate's updates are deduped already (decision 101). The Home's components each style their own big numbers and mono labels (the app counts, the week's hours, the section head's note); shared type styles could replace the copies. The header's nav marks an app's link `aria-current="page"` on the app's deeper pages too (`/notes/x`); ARIA and GOV.UK's service navigation keep "page" for the link's own page and use "true" for a section the page is in, as the tab bar's More does (decision 52). Screen readers aren't told when a page changes in place; a polite live region that reads out the new title after each navigation could tell them, as SvelteKit's route announcer does. Each service's port is written twice in `process-compose.yaml`, in its `BAKHYARD_LISTEN` and its probe, and the gateway's `BAKHYARD_PEERS` repeats the other services'; a check could keep them in step. Nothing checks that every address is on the loopback, though nothing should listen on the LAN (§3). Pushover could join Web Push as a second notification channel, for alerts that repeat until acknowledged (§2, decision 64). After signing out, Back may bring back the signed-in page from the browser's back-forward cache, data included; the app could check its session again when a page is restored (`pageshow` with `persisted`). The web app's dev server (`//apps/web:dev`) proxies `/api` to the gateway on the loopback, which has answered 403 there since `auth-tailscale` (decision 77), so it shows the app without data; local development needs a way through the lock. The Notes filter matches the notes' paths only; a full-text search could look inside them too. A note with a dot in its name gets the web service's 404 when its page is reloaded (decision 94); the service could serve the app for every missing path under `/notes/`. Back and Forward can't put a note's page where it was left: the browser restores the scroll position once the page shows, before the note has loaded; the page could restore it itself once the note is in (the Navigation API's `scroll: 'manual'` and `event.scroll()`). Footnote links in a note go nowhere: rehype-sanitize prefixes the footnotes' ids but not the links to them, which GitHub fixes with a script (the vault has no footnotes yet). A task's checkbox has no accessible name, as on GitHub; it could take its item's text. These link forms wait until the vault uses them (decision 97): a note shown inline, where `![[Note]]` is a link today; heading and block links into other notes, which open the note at its top; links in a list property; links to files that aren't notes; relative paths (`../`); and links that match a note's name in another case. An image embed's size waits the same way (decision 98): `![[x.png|300]]` and `![[x.png|300x200]]` show the image at its own size today. So do embeds of other files, such as audio, video and PDFs, Markdown images of the vault's files (`![](x.png)`), and a way to see an image larger. Callouts, `> [!note]`, wait until the vault has one (decision 99). KaTeX's red for a formula that doesn't parse, `#cc0000`, has about 3.2:1 contrast on the dark paper, under WCAG AA's 4.5:1; a colour of our own could replace it. Every note's page loads KaTeX with the renderer, though one note in the vault has math; KaTeX could load only for a note that has some. The build also ships KaTeX's woff and ttf fonts, which a browser loads only when it can't read woff2, the first format KaTeX's stylesheet lists. Svelte's docs advise against the `class:` directive since 5.16, when the `class` attribute began to take an object or an array; `todos-groups` uses the attribute, while `AppCards` and the Notes `Tree` still use the directive. No test runs a migration's Down; a test could take each service's database down a migration and up again, so a Down that doesn't undo its Up fails. A todo dragged near the screen's top or bottom edge could scroll the page, so a todo on a long list can go further than the screen shows; today the user scrolls first, or uses the editor's moves (decision 120). Escape could cancel a drag. A dragged todo's row could follow the pointer while the other rows slide aside to make room, as iOS lists do, rather than a line marking the place; across groups, the cards would have to grow and shrink as it passes (decision 120). A todo could be dropped into a priority that has no todos, which has no section to drop it in; the editor's priority does that (decision 117). Thirteen components copy the card's style, a 1 px `--rule` border on the `--surface` with the offset shadow, from the Home's "Needs you" to the Time page's days; a shared class could replace the copies, as `list.css` did for the rows (decision 124). The end-to-end tests keep no trace of a failed test, so a flaky one can't be looked into afterwards; `trace: 'retain-on-failure'` would keep one. In `todos-ticket`, the Move test's five-second wait for the second move ran out twice in `mise run check`, on battery in Low Power Mode, though the move did land just after; it passed in 660 runs alone or in the whole suite. A linked todo's badge could link to its ticket in Jira; it sits in the box's label, where a tap ticks the todo, so the link needs a target of its own, at least 24 px and apart from the label (WCAG 2.2 SC 2.5.8, decision 126). The Jira inbox keeps a dismissal for good, so a dismissed ticket that leaves my open tickets and comes back later, reassigned to me or reopened, stays out of the inbox; the service could forget a dismissal once a search that succeeds no longer finds its ticket (decision 128). An inbox row could link to its ticket in Jira, as the Jira section's rows do; the row isn't a link, since it holds an Add button, so the link needs a target of its own, as the badge's does (decision 129). The Notes page's Sync could also make Gitea mirror the vault at once, through Gitea's `POST /repos/{owner}/{repo}/mirror-sync`, so a note that the Mac pushes shows on the laptop within a minute rather than up to ten (decision 151). The images workflow builds and tags all six images on every change, so Flux restarts every service each time (decision 152); it could build only the images that a change touches. GitHub's CI could build the images on each PR too, without pushing them, so a Dockerfile that no longer builds fails before the merge rather than in Gitea after it. The five Go services' Dockerfiles differ only in the service's name; one Dockerfile with the name as a build argument could replace them (decision 153). The Jira inbox's rule lives in two places, the Todos page's `inboxTickets` and todos' attention lister (decision 160); the page could take the inbox from todos through a JSON route, so the rule lives in one. The services talk in plaintext inside the cluster, gRPC over h2c and HTTP/1.1, behind the NetworkPolicies (decisions 137 and 158); mutual TLS between them, from a service mesh or from cert-manager's certificates, would encrypt that traffic. SonarQube Cloud's rule for clear-text protocols, S5332, looks for an `http://` literal, so it doesn't see these calls (decision 168). The services' connections to Postgres are encrypted but don't check the server's certificate (`sslmode=require`); with CloudNativePG's CA mounted and `sslmode=verify-full`, they would (decision 170). pulls could find my Bitbucket workspaces itself, through `GET /2.0/user/workspaces`, which needs the scope `read:workspace:bitbucket` that today's token lacks, and drop `BAKHYARD_BITBUCKET_WORKSPACES`. The time report's target is 8 hours each weekday, so a public holiday or a day off shows as a missing day (decision 180); a setting could list the days without a target. ## 1. Goal and scope @@ -961,6 +962,7 @@ Cards are solid `--surface`, so they sit on the grain like paper on a desk. Thes - A row whose editor isn't open ends with a grip, after Edit (built in `todos-drag`, decision 120): six dots on the 16-unit icon grid, in `--ink-muted`, turning `--ink` under the pointer on devices that hover, with the grab cursor. It's 44 px wide and as tall as the row, with the dots centred on the title's first line. Dragging it lifts the row where it is, as a link card lifts on hover: its border turns `--ink` and its shadow grows to `--offset-hover` in `--shadow-strong`, with the grabbing cursor and no hover fill. No row moves during a drag; a 2 px `--ink` line follows the pointer, across the top of the row the todo would land before, or across the bottom of a group's last row, to land after it, and no line shows where the todo would stay. While a drop or the editor waits for the service, the grips show the progress cursor. When a drop fails, the row's right end says "Move failed" in the mono status style, until the next drop. - My open Jira tickets have a section of their own after the priority groups, or after "Couldn't load the todos.", once the todos show or fail to load (built in `jira-list`, decision 124): "Jira *tickets*", with the count as the note, "1 ticket" or "3 tickets". Its card holds a list row per ticket, in the service's order, that links to the ticket in Jira: the key as the row's mono label, the summary as its title, and the status at its end in a chip, filled for the In Progress category and outlined for the others, at most 160 px wide and wrapping inside. With no ticket, the card says "No open tickets." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These tickets are from 21 October at 09:38." when it has tickets from an earlier search. Any other failure to load: "Couldn't load the tickets." under the head. Without Jira, no section. - Pull requests, built in `pulls-page` (decision 149): "Pull requests", with no summary. Under the head, my open PRs sit in a section, "Your *PRs*", with the count as the note, "1 PR" or "3 PRs". Its card holds a list row per PR, in the service's order, the latest updated first, that links to the PR on Bitbucket: the number as the row's mono label ("#128"), the title as its title, the repository's full name ("acme/web") under it as its detail, and, on a draft, a Draft chip at its end, outlined as the Jira section's chips that aren't In Progress. Under the repository, built in `approvals-page` (decision 174), a second detail line says where the PR stands with its reviewers, a group for each verdict that someone has given, the most urgent first, split by " · ": "Changes requested by Noor Aziz · Waiting for Tom Berg and Leo Hart · Approved by Mia Chen"; with no reviewers, "No reviewers". A PR without a link shows in a row that isn't a link. With no PR, the card says "No open pull requests." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These PRs are from 21 October at 09:38." when it has PRs from an earlier search. Any other failure to load: "Couldn't load the pull requests." under the section's head. Under it, built in `reviews-page` (decision 172), the PRs that wait for my review sit in a section of their own, "Review *requests*", 64 px below my PRs' card (48 px on a phone), with the same rows, without the review line, and the same note and failed-search lines. With none, its card says "No pull requests to review."; any other failure to load: "Couldn't load the review requests." under its head. Each section shows once its own list loads, so neither waits for the other, and nothing shows for a list while it loads. Without Bitbucket, when both lists answer 404, "Bitbucket isn't set up." in `--ink-muted` under the page head, with no section; when only one does, the page leaves its section out. +- Time, built in `time-page` (decision 184): "Time", with no summary. Under the head, this week's hours sit in a section, "This *week*", on the card that the Home's "This week" shows (below), without its link. Under it, 64 px below the card (48 px on a phone), the last 7 days sit in a section, "Last 7 *days*", with the missing days' count as the note, "1 day missing" or "3 days missing", left out when none is. Its card holds a list row per day, in the report's order, the earliest first, and none is a link: the date as the row's mono label ("15 Oct"), the weekday as its title ("Thursday"), or "Today" for the last day, and at its end the hours logged ("7h 30m", "0h"), in the end's mono capitals, in `--ink` when any are logged and `--ink-muted` when none are. A weekday before today with no hours has a Missing chip before them, filled as a late item's chip. Nothing shows under the head while the report loads, which it does each time the page shows. When the last search failed, a muted line under the head says "The last search failed: .", then "These hours are from 21 October at 09:38." when it has days from an earlier search, 30 px above the first section (24 px on a phone), as the Todos page's form; with no days yet, the line alone. Without Jira, "Jira isn't set up." in `--ink-muted` under the head, with no section; any other failure to load: "Couldn't load the time report.". - More: "More", with the summary "The apps without a tab of their own.", then the cards of Time, GitHub, Games and Planner as in "Your apps", with their numbers (04 to 07) and counts. The cards' names are `h2`s, under the page's `h1`. The grid ends 64 px above the page's end (48 on phones), as a section does. - Any other path: "Page not found", with "There's no page at /elsewhere." as the summary. @@ -1204,7 +1206,7 @@ Every item is pending unless it says done. The service an item lands in is in br - `homelab-time`: done (codedogapp/homelab#42). In the homelab repo, `time` runs on the laptop with todos' Jira settings and token, and the gateway's and home's peers name it. - `time-gateway`: the gateway forwards `/api/time` to `time`. Done (#151). - `week-card`: the Home's week card, its formatting and the week's type move to `src/time`, for the Time page to share; nothing changes on screen (decision 183). Done (#152). - - `time-page`: the Time page shows the time report. + - `time-page`: the Time page shows the time report: this week's card, then the last 7 days with the missing ones marked (decision 184). Done (#153). - `time-week`: `time` answers `TimeService/GetWeek` (§4.10). - `home-week`: home asks `time` for the week, and the Home's "This week" shows it, or "Hours unavailable" when `time` doesn't answer (§7). - `time-attention`: `time` answers the attention contract with today's hours until they're logged, and each missing weekday. @@ -1557,6 +1559,14 @@ Made on 2026-10-23: - **The image**: `services/time/Dockerfile` builds as pulls' does, and the Gitea workflow builds and pushes `time` with the other services. - **Rejected**: the poller inside `report`, which would bring the clock and Jira into it; a Refresh that polls on demand; embedding `time/tzdata`; `time.Now()` read twice, once for the span and once for the report, which could disagree across midnight. 183. **The Time page in two steps**: the lead's choices (2026-10-29), for the user to review. The Time page shows this week's hours against the target on the card that the Home's "This week" shows, so the two share it. The card sat inside the Home's `ThisWeek`, under the section head whose "Day report" link leads to the Time page, with its formatting and the `Week` type in the Home's folder. So `time-page` splits in two and `time-tracking` has thirteen steps (§8.2): `week-card`, a pure refactor, moves the card into a component of its own, `WeekCard`, and moves it, `week.ts` and the `Week` type to the Time app's folder, `src/time`, as `shell-section` (#85) moved the section head ahead of `todos-groups`; `time-page` then builds the page. The Home takes the week's card from the Time app, as home will take the week from the time service (`home-week`). Rejected: the Time page importing the Home's files, which would make an app depend on the Home, as `MorePage` does on its app cards; a copy of the card. +184. **The Time page** (`time-page`): the lead's choices (2026-10-29), for the user to review. The Time page, at `/time`, shows the time report that the gateway forwards from the time service, `GET /api/time/report` (decisions 181 and 182). + - **The head**: "Time", with no eyebrow and no summary, as the PRs page's head: the sections' heads and notes say what the page holds. Nothing shows under it while the report loads. + - **This week**: a section, "This *week*", with the Home's week card (decision 183) and no link, since the Home's "Day report" link leads here. + - **The last 7 days**: a section, "Last 7 *days*", whose note counts the missing days, "1 day missing" or "3 days missing", and is left out when none is. Its card holds a list row per day (decision 123), in the report's order, the earliest first, so the days read as a calendar's do and today ends them: the date as the mono label ("15 Oct"), the weekday as the title ("Thursday"), and "Today" for the last day, which the report's contract makes today (decision 181). At the row's end come the hours, through the week card's formatting ("7h 30m", "0h", in the end's mono capitals), in `--ink` when any are logged and muted like the date when none are, so the days with hours stand out. A missing day has a Missing chip before its hours, filled as the Home's Late chip, since it's something to put right. The rows aren't links: `fill-day` gives a missing day's row its action. + - **Dates as days**: a report's date names a day in the time service's zone, not a moment. A date-only ISO string parses as UTC midnight, so the page formats it in UTC too (`timeZone: 'UTC'`), and the day reads the same whatever the device's zone; formatted in the device's zone, every date would read as the day before west of UTC. + - **Loading and failures**: the report loads each time the page shows, as the PRs page's lists do (decision 149), through the shell's loader of a poller's snapshot (decision 147). A 404 says "Jira isn't set up.", and any other failure "Couldn't load the time report.", under the page head with no section. When the last search failed, one line under the head says why and, when there are days from an earlier search, adds "These hours are from 21 October at 09:38."; one line for the page rather than one in each section, since one snapshot feeds both. Over the sections it leaves the 30 px (24 on phones) that the Todos page's form leaves over its first section (decision 112); with no days yet, the line ends the page, as "Jira isn't set up." does. + - **Code**: `src/time/report.ts` (the `Report` type and `loadReport`), `days.ts` (`dayRows` and `missingNote`), `DayList.svelte` and `TimePage.svelte`; `week.ts` now exports `formatHours` for the rows. The shell's pages gain a `time` kind, so `/time` and the paths under it show the page, as with `/todos` and `/pulls`. The app's tests of the pages it opens on move to a file of their own, `App.pages.test.ts`, since `App.test.ts` had 199 of Biome's 200 lines. `DayList` copies the card's style, as twelve other components do (§0's ideas). + - **Rejected**: a failure line in each section; the newest day first, which runs the week backwards; the weekday as the row's label, with "Today" as a detail; Temporal's `PlainDate` for the dates, where one `Intl` option does; a summary under the head, which would repeat the sections' notes. ## 10. Risks diff --git a/docs/setup.md b/docs/setup.md index 768decb..7b299c2 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -142,7 +142,7 @@ Todos lists your open Jira tickets: those assigned to you whose status isn't in Both URLs must be https, so the token never travels in the clear; only a loopback IP address, such as `http://127.0.0.1:8090`, may take http, for a stand-in Jira in tests. With only some of the settings, a URL that isn't https or no token in the Keychain, todos exits 1 and says which: `BAKHYARD_JIRA_EMAIL is not set`, `BAKHYARD_JIRA_API: "…" is not an https URL` or `read the Jira token: no Keychain item with service bakhyard and account jira-token`. Without any of the settings, `GET /api/todos/tickets` answers 404 `Jira is not set up`. Todos polls Jira as it starts and every 5 minutes after (decisions 122 and 133): it searches your open tickets, then fetches the other tickets that your todos left to do name. When Jira refuses the token, for example once it has expired, `GET /api/todos/tickets` keeps answering what the last poll to succeed found, adds Jira's answer as its `failure`, such as `search Jira: 401 Unauthorized` or `fetch Jira tickets: 401 Unauthorized`, and todos logs `poll Jira` with it. To replace the token, add `-U` to the command in step 3, then restart todos and time. -Time reads your worklogs with the same settings and token (decisions 180 to 182). As it starts and every 5 minutes after, it reads the worklogs you logged in the last 7 days, today included, and works out the time report: the hours you logged on each day, the weekdays before today with nothing logged, and this week's hours against 40. Its days follow its time zone: the Mac's, or a container's `TZ`, without which a container keeps UTC. `mise run up` runs it on port 8088, where `GET /time/report` answers the report, with when the latest poll to succeed began and why the latest poll failed, if it did; the gateway doesn't forward it yet. Without any of the Jira settings, time answers 404 `Jira is not set up`; with only some, it exits 1 and says which, as todos does. When Jira refuses the token, time keeps answering the last report it worked out, adds Jira's answer as its `failure`, such as `read the Jira account: 401 Unauthorized`, and logs `poll Jira worklogs` with it. +Time reads your worklogs with the same settings and token (decisions 180 to 182). As it starts and every 5 minutes after, it reads the worklogs you logged in the last 7 days, today included, and works out the time report: the hours you logged on each day, the weekdays before today with nothing logged, and this week's hours against 40. Its days follow its time zone: the Mac's, or a container's `TZ`, without which a container keeps UTC. `mise run up` runs it on port 8088, where `GET /time/report` answers the report, with when the latest poll to succeed began and why the latest poll failed, if it did; the gateway forwards it as `GET /api/time/report`, which the Time page shows. Without any of the Jira settings, time answers 404 `Jira is not set up`; with only some, it exits 1 and says which, as todos does. When Jira refuses the token, time keeps answering the last report it worked out, adds Jira's answer as its `failure`, such as `read the Jira account: 401 Unauthorized`, and logs `poll Jira worklogs` with it. ## Bitbucket