Release / Build and publish image (push) Failing after 10m6s
Reported from use: "Fetch now" said "something went wrong" and the error log was empty. Both halves were the same bug wearing two hats. `handleGmailSyncNow` answered 502 with the code `internal_error`, which the interface maps to a generic sentence — so Google's own words, which are the whole diagnosis, were replaced by one that says nothing. And `writeError` records nothing; only `s.internal` does. So there was no message to read and nowhere to look it up afterwards. `s.upstream()` is the answer: 502 because this is somebody else's service saying no rather than this server breaking, a code of its own so the interface can quote what was said, and a row in the error log — which is the entire reason that log exists. A Gmail sync failing quietly for a week is otherwise invisible: the background job writes a line the next restart erases. The same connection also left the mailbox unnamed. The address is learned at connect time and that call can fail; a warning went to the log and an empty string to the database, so the page said "an unknown account" and kept saying it. The only way back was to disconnect and reconnect, which nobody would guess. The status endpoint asks again when it finds none, so a wrong answer lasts until the next look rather than until somebody reconnects. Ten Danish strings were hiding in `confirm()` and `app.toast()` calls, including every destructive confirmation in the app — the worst possible set to leave in one language, since it is what somebody reads in the half-second before agreeing to lose something. The i18n guard reads markup and attributes, and a string argument lives in the script block. It reads those now, and found four my own grep missed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
392 lines
9.8 KiB
Svelte
392 lines
9.8 KiB
Svelte
<script>
|
|
/**
|
|
* Kanban: one column per section, plus one for everything not filed under a
|
|
* section. Dragging a card between columns moves the task.
|
|
*
|
|
* The drag uses the native HTML drag-and-drop API rather than pointer events.
|
|
* It is fiddlier to style, but it is the one that works with a trackpad, a
|
|
* mouse and a screen reader's drag affordance without reimplementing any of
|
|
* them — and it gives keyboard users the browser's own behaviour for free.
|
|
*/
|
|
import { app } from '$lib/stores.svelte.js';
|
|
import { api, humanMessage } from '$lib/api.js';
|
|
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
|
|
import { focusOnMount } from '$lib/focus.js';
|
|
import TaskRow from './TaskRow.svelte';
|
|
import { t } from '$lib/i18n.svelte.js';
|
|
|
|
let { project, sections, canEdit, onsectionadded } = $props();
|
|
|
|
// --- adding a column ------------------------------------------------------------
|
|
//
|
|
// A column on a board *is* a section, so this is where somebody looks for one.
|
|
// It was only in the list view, which meant a board — the view whose entire
|
|
// shape is its sections — had no way to grow one. Reported as "sections have no
|
|
// function: there is no way to create them", which from inside a board is
|
|
// exactly true.
|
|
|
|
let adding = $state(false);
|
|
let name = $state('');
|
|
|
|
/** Same as the list view's: through quick add, so a date or a p1 still parses. */
|
|
let addingIn = $state(null);
|
|
let newInSection = $state('');
|
|
|
|
async function addToSection(event, sectionId) {
|
|
event.preventDefault();
|
|
const text = newInSection.trim();
|
|
if (!text) return;
|
|
newInSection = '';
|
|
addingIn = null;
|
|
try {
|
|
app.upsert(await api.quickAdd(text, project.id, sectionId));
|
|
} catch (e) {
|
|
app.toast(humanMessage(e));
|
|
}
|
|
}
|
|
|
|
async function addSection(event) {
|
|
event.preventDefault();
|
|
const trimmed = name.trim();
|
|
if (!trimmed) return;
|
|
adding = false;
|
|
name = '';
|
|
try {
|
|
onsectionadded?.(await api.createSection(project.id, trimmed));
|
|
} catch (e) {
|
|
app.toast(humanMessage(e));
|
|
}
|
|
}
|
|
|
|
let draggingId = $state(null);
|
|
let overColumn = $state(null);
|
|
|
|
// The unsectioned column comes first, as it does in the list view: it is where
|
|
// quick add puts things, so it is where new work appears.
|
|
let columns = $derived([
|
|
{ id: '', name: 'Uden sektion' },
|
|
...sections.map((s) => ({ id: s.id, name: s.name }))
|
|
]);
|
|
|
|
// Scoped to this project, not just to the section: the store holds whatever the
|
|
// last view loaded, and a task that has left the project would otherwise turn
|
|
// up in the "no section" column, which is exactly where a task with no section
|
|
// belongs — in some other project.
|
|
const tasksIn = (sectionId) =>
|
|
app.tasks
|
|
.filter(
|
|
(t) =>
|
|
!t.completed &&
|
|
!t.parent_id &&
|
|
t.project_id === project.id &&
|
|
(t.section_id ?? '') === sectionId
|
|
)
|
|
.sort((a, b) => a.sort_order - b.sort_order);
|
|
|
|
function onDragStart(event, task) {
|
|
if (!canEdit) return;
|
|
draggingId = task.id;
|
|
startDrag(event, TASK, task.id);
|
|
}
|
|
|
|
function onDragOver(event, sectionId) {
|
|
if (!canEdit || !carries(event, TASK) || !draggingId) return;
|
|
accept(event);
|
|
overColumn = sectionId;
|
|
}
|
|
|
|
async function onDrop(event, sectionId) {
|
|
event.preventDefault();
|
|
overColumn = null;
|
|
const id = draggingId;
|
|
draggingId = null;
|
|
if (!id || !canEdit) return;
|
|
|
|
const task = app.get(id);
|
|
if (!task || (task.section_id ?? '') === sectionId) return;
|
|
|
|
// Optimistic: the card is in the new column before the request goes out,
|
|
// because a card that snaps back for 200ms reads as a failed drop.
|
|
const previous = { ...task };
|
|
app.replace(id, { ...task, section_id: sectionId });
|
|
|
|
try {
|
|
// Dropped at the end of the column: the task after it is nothing, and
|
|
// the one before it is whatever was last there.
|
|
const existing = tasksIn(sectionId).filter((t) => t.id !== id);
|
|
const after = existing.at(-1);
|
|
app.replace(
|
|
id,
|
|
await api.moveTask(id, {
|
|
project_id: project.id,
|
|
section_id: sectionId,
|
|
after_id: after?.id ?? ''
|
|
})
|
|
);
|
|
} catch {
|
|
app.replace(id, previous);
|
|
app.toast(t('task.moveFailed'));
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="board">
|
|
{#each columns as column (column.id)}
|
|
{@const tasks = tasksIn(column.id)}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<section
|
|
class="column"
|
|
class:over={overColumn === column.id}
|
|
ondragover={(e) => onDragOver(e, column.id)}
|
|
ondragleave={() => (overColumn = null)}
|
|
ondrop={(e) => onDrop(e, column.id)}
|
|
>
|
|
<header>
|
|
<h2>{column.name}</h2>
|
|
<span class="count">{tasks.length}</span>
|
|
</header>
|
|
|
|
<div class="cards">
|
|
{#each tasks as task (task.id)}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="card"
|
|
class:dragging={draggingId === task.id}
|
|
draggable={canEdit}
|
|
ondragstart={(e) => onDragStart(e, task)}
|
|
ondragend={() => (draggingId = null)}
|
|
>
|
|
<TaskRow {task} />
|
|
</div>
|
|
{/each}
|
|
|
|
{#if !tasks.length}
|
|
<p class="empty">{t('view.emptySection')}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- At the foot of the column, where the next card would go. A board's
|
|
columns *are* the sections, so this is the one place where "add a
|
|
task here" needs no explanation at all. -->
|
|
{#if canEdit}
|
|
<!-- `column`, which is what the each binds. The unsectioned column has
|
|
an empty id, and that is exactly what quick add wants for "in this
|
|
project, no section". -->
|
|
{#if addingIn === column.id}
|
|
<form class="add-task" onsubmit={(e) => addToSection(e, column.id)}>
|
|
<input
|
|
bind:value={newInSection}
|
|
use:focusOnMount
|
|
placeholder={t('view.addTaskHere')}
|
|
aria-label={t('view.addTaskIn', { name: column.name })}
|
|
onblur={() => !newInSection.trim() && (addingIn = null)}
|
|
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
|
|
/>
|
|
</form>
|
|
{:else}
|
|
<button
|
|
class="add-task-open"
|
|
onclick={() => {
|
|
addingIn = column.id;
|
|
newInSection = '';
|
|
}}>{t('view.addTask')}</button
|
|
>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
{/each}
|
|
|
|
{#if canEdit}
|
|
<section class="column adder">
|
|
{#if adding}
|
|
<form onsubmit={addSection}>
|
|
<input
|
|
bind:value={name}
|
|
use:focusOnMount
|
|
placeholder={t('view.sectionName')}
|
|
aria-label={t('view.newSection')}
|
|
onblur={() => !name.trim() && (adding = false)}
|
|
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
|
|
/>
|
|
</form>
|
|
{:else}
|
|
<button
|
|
class="add"
|
|
onclick={() => {
|
|
adding = true;
|
|
name = '';
|
|
}}>{t('view.addSection')}</button
|
|
>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.board {
|
|
display: flex;
|
|
gap: var(--s3);
|
|
align-items: flex-start;
|
|
overflow-x: auto;
|
|
padding-bottom: var(--s4);
|
|
/* Columns snap as you swipe on a phone, so a half-column never sits at
|
|
the edge looking like a rendering fault. */
|
|
scroll-snap-type: x proximity;
|
|
}
|
|
|
|
/* Narrower than a real column and without its background: it is an invitation
|
|
at the end of the row, not a column with nothing in it. */
|
|
.adder {
|
|
background: none;
|
|
border: 1px dashed var(--line);
|
|
min-width: 180px;
|
|
padding: var(--s2);
|
|
}
|
|
|
|
.adder .add {
|
|
width: 100%;
|
|
padding: var(--s2);
|
|
font-size: var(--text-sm);
|
|
color: var(--ink-faint);
|
|
border-radius: var(--radius);
|
|
text-align: left;
|
|
}
|
|
|
|
.adder .add:hover {
|
|
color: var(--ink);
|
|
background: var(--surface);
|
|
}
|
|
|
|
.adder input {
|
|
width: 100%;
|
|
padding: var(--s2);
|
|
background: var(--surface-sunken);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
font-size: var(--text-sm);
|
|
outline: none;
|
|
}
|
|
|
|
.adder input:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.column {
|
|
flex: 0 0 300px;
|
|
max-width: 300px;
|
|
background: var(--surface-sunken);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-lg);
|
|
padding: var(--s3);
|
|
scroll-snap-align: start;
|
|
transition: border-color var(--fast) var(--ease);
|
|
}
|
|
|
|
/* The drop target is shown by the column it would land in, not by a line
|
|
between cards: the column is the decision being made here. */
|
|
.column.over {
|
|
border-color: var(--accent);
|
|
background: var(--accent-sunken);
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: var(--s2);
|
|
padding: 0 var(--s2) var(--s3);
|
|
}
|
|
|
|
h2 {
|
|
font-size: var(--text-xs);
|
|
font-weight: 560;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--ink-faint);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.count {
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
flex: none;
|
|
}
|
|
|
|
.cards {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--s2);
|
|
min-height: 60px;
|
|
}
|
|
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
padding: 0 var(--s2);
|
|
cursor: grab;
|
|
transition:
|
|
opacity var(--fast) var(--ease),
|
|
border-color var(--fast) var(--ease);
|
|
}
|
|
|
|
.card:hover {
|
|
border-color: var(--line-strong);
|
|
}
|
|
|
|
.card.dragging {
|
|
opacity: 0.4;
|
|
cursor: grabbing;
|
|
}
|
|
|
|
/* The row draws its own bottom border for the list view, which is a stray
|
|
line inside a card. */
|
|
.card :global(.row) {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.card :global(.row:hover) {
|
|
background: transparent;
|
|
}
|
|
|
|
.add-task-open {
|
|
align-self: flex-start;
|
|
padding: var(--s2);
|
|
font-size: var(--text-sm);
|
|
color: var(--ink-faint);
|
|
opacity: 0;
|
|
transition: opacity var(--fast) var(--ease);
|
|
}
|
|
|
|
.column:hover .add-task-open,
|
|
.add-task-open:focus-visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.add-task-open:hover {
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.add-task input {
|
|
width: 100%;
|
|
padding: var(--s2);
|
|
background: var(--ground);
|
|
border: 1px solid var(--accent);
|
|
border-radius: var(--radius);
|
|
font: inherit;
|
|
font-size: var(--text-sm);
|
|
color: var(--ink);
|
|
outline: none;
|
|
}
|
|
|
|
.empty {
|
|
margin: 0;
|
|
padding: var(--s3) var(--s2);
|
|
color: var(--ink-faint);
|
|
font-size: var(--text-sm);
|
|
text-align: center;
|
|
}
|
|
</style>
|