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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions apps/web/e2e/demo.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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',
};
133 changes: 133 additions & 0 deletions apps/web/e2e/time.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
62 changes: 62 additions & 0 deletions apps/web/src/App.pages.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>(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);
});
3 changes: 3 additions & 0 deletions apps/web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -73,6 +74,8 @@ $effect(() =>
<TodosPage />
{:else if page.kind === 'pulls'}
<PullsPage />
{:else if page.kind === 'time'}
<TimePage />
{:else if page.kind === 'nudge'}
<NudgePage />
{:else if page.kind === 'app'}
Expand Down
28 changes: 0 additions & 28 deletions apps/web/src/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/shell/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' },
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/shell/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand All @@ -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 === '/') {
Expand All @@ -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' };
}
Expand Down Expand Up @@ -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':
Expand Down
Loading
Loading