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 @@ -281,7 +281,7 @@ creates the profile from `DEFAULT_FIRST_NAME` instead of the first-run screen.

```bash
cd backend && ruff check . && ruff format --check . && pytest # 343 tests, no database needed
cd frontend && npm run lint && npm test # 223 tests, jsdom
cd frontend && npm run lint && npm test # 232 tests, jsdom
```

The backend suite runs against in-memory SQLite, so there is nothing to
Expand Down
19 changes: 19 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,25 @@ propagation without clipping anything; `overflow-x: hidden` on `html`, on `body`
and on the page container all changed nothing. That is why the `table-scroll`
utility exists instead of a bare `overflow-x-auto`.

**A flex item's width is fit-content, not "fill the parent".** The footer
turned the page into a `min-h-screen` column so it sits at the bottom on a
short screen rather than below the fold. That made the dashboard a flex item,
and a flex item takes its cross size from its content — so the activity table
pushed it to 339px inside a 320px column and dragged the document out with it,
the same 19px overflow the `table-scroll` utility had just been written to
stop. `*:w-full` on the column gives every child an explicit width and takes
the choice away. Third of a family now: a grid item needs `min-width: 0` to be
allowed to shrink, a flex item needs a width to be made to fill, and neither
default is the one a page layout wants.

**The app says where it came from, on every screen.** Somebody can arrive in
front of a running Activity Hub without having deployed it — a NAS in the
house, a server set up years ago — and the running app is then their only
clue, so the footer carries the link out to the project. Including on the
screen that reports an unreachable API: a person whose stack will not come up
is exactly the person who wants the repository, and that is where they are
stuck.

**A proportion bar that cannot show a proportion is decoration.** The zone rows
are three columns where there is room, and on a phone the bar drops to its own
line under the name and the time. Squeezed into a third of a 358px screen its
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { errorMessage, fetchProfile } from './api/client'
import Dashboard from './components/Dashboard'
import FirstRunScreen from './components/FirstRunScreen'
import Footer from './components/Footer'

/**
* Decides between the first-run screen and the dashboard.
Expand All @@ -26,6 +27,24 @@ export default function App() {
}
}, [])

// One shell around every state, so the footer is on all of them — and a
// column with a growing body rather than a full-height view plus a footer
// after it, which would push the footer below the fold on a short screen.
//
// `*:w-full` is load-bearing, in the same family as the `*:min-w-0` in the
// dashboard's grids. Whatever this box holds becomes a flex item, and a flex
// item's cross size is fit-content rather than "fill the parent" — so the
// activity table dragged the dashboard out to 339px inside a 320px column,
// and the document with it. An explicit width takes that away.
return (
<div className="flex min-h-screen flex-col">
<div className="flex flex-1 flex-col *:w-full">{view(state, setState)}</div>
<Footer />
</div>
)
}

function view(state, setState) {
if (state.status === 'loading') {
return (
<p className="p-8 text-sm text-slate-600 dark:text-slate-400" role="status">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/FirstRunScreen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default function FirstRunScreen({ onCreated }) {
}

return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6">
<main className="mx-auto flex max-w-md flex-1 flex-col justify-center px-6 py-12">
<h1 className="text-2xl font-semibold">Welcome to Activity Hub</h1>
<p className="mt-2 text-sm muted">
Your activity files do not carry your name, so tell us once and we will not ask again.
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/components/Footer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const REPOSITORY = 'https://github.com/Danielededo/activity-hub'

/**
* Says where this app came from.
*
* Someone can arrive in front of a running Activity Hub without having been
* the one who deployed it — a NAS in the house, a server someone set up years
* ago — and the running app is then the only clue they have. So the footer
* carries the link out to the project and its licence.
*
* Rendered on every screen, including the one that reports an unreachable API:
* a person whose stack will not come up is exactly the person who wants the
* repository, and that screen is where they are stuck.
*/
export default function Footer() {
return (
<footer className="mt-12 border-t border-[var(--border)]">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-6 gap-y-2 px-4 py-6 text-xs">
<p className="muted">Activity Hub — your training, on your own machine.</p>

{/* Underlined, not merely coloured: these sit in body ink, so colour
alone would not say they are links. */}
<p className="flex flex-wrap items-center gap-x-4 gap-y-2">
<a
href={REPOSITORY}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 underline underline-offset-2"
>
<GitHubMark />
Source on GitHub
</a>
<a
href={`${REPOSITORY}/blob/main/LICENSE`}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2"
>
MIT licence
</a>
</p>
</div>
</footer>
)
}

/** The mark people scan a footer for. Decorative: the link text carries it. */
function GitHubMark() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.4 7.4 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A7.99 7.99 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
)
}
15 changes: 15 additions & 0 deletions frontend/tests/App.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ describe('App', () => {
expect(screen.getByRole('alert')).toHaveTextContent('Network Error')
})

it.each([
['the first-run screen', () => api.fetchProfile.mockResolvedValue(null)],
['the dashboard', () => api.fetchProfile.mockResolvedValue(PROFILE)],
// The one that earns the shell: somebody whose stack will not come up is
// exactly the person who wants the repository, and this is where they stop.
['the unreachable-API screen', () => api.fetchProfile.mockRejectedValue(new Error('nope'))],
])('shows where the project lives on %s', async (_name, arrange) => {
arrange()

render(<App />)

await waitFor(() => expect(screen.getByRole('contentinfo')).toBeVisible())
expect(screen.getByRole('link', { name: /source on github/i })).toBeVisible()
})

it('moves on to the dashboard once the profile is created', async () => {
api.fetchProfile.mockResolvedValue(null)
api.createProfile.mockResolvedValue(PROFILE)
Expand Down
56 changes: 56 additions & 0 deletions frontend/tests/Footer.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Footer from '../src/components/Footer'

const REPOSITORY = 'https://github.com/Danielededo/activity-hub'

describe('Footer', () => {
it('is a footer landmark', () => {
render(<Footer />)

expect(screen.getByRole('contentinfo')).toBeVisible()
})

it('links out to the project', () => {
render(<Footer />)

const source = screen.getByRole('link', { name: /source on github/i })
expect(source).toHaveAttribute('href', REPOSITORY)
})

it('links to the licence itself, not just its name', () => {
render(<Footer />)

expect(screen.getByRole('link', { name: /mit licence/i })).toHaveAttribute(
'href',
`${REPOSITORY}/blob/main/LICENSE`,
)
})

it('opens outward links in a new tab without handing over the referrer', () => {
// The app is self-hosted; navigating away from it should not close it, and
// a local address is nobody else's business.
render(<Footer />)

for (const link of screen.getAllByRole('link')) {
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noreferrer')
}
})

it('does not rely on colour to say a link is a link', () => {
render(<Footer />)

for (const link of screen.getAllByRole('link')) {
expect(link.className).toMatch(/underline/)
}
})

it('hides the decorative mark from a screen reader', () => {
// The link text already says where it goes; the mark would only repeat it.
const { container } = render(<Footer />)

const svg = container.querySelector('svg')
expect(svg).toHaveAttribute('aria-hidden', 'true')
})
})
Loading