diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 965df77..4e0c325 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -3378,3 +3378,48 @@ test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page
expect(trouble).toEqual([]);
});
+
+/**
+ * Et projekts kalender kan vises som en uge.
+ *
+ * Ugen fandtes på Kommende og på Kalender, men ikke dér, hvor et projekt bor — og
+ * det er dér, "hvornår sker de her fire ting" oftest bliver spurgt.
+ *
+ * Inde i kalendervisningen frem for som en fjerde `view_mode`. Den er projektets
+ * og delt med alle, der kan se det, mens uge eller måned er et spørgsmål om, hvor
+ * bred skærmen er, foran hvilken der sidder én person — og en fjerde værdi ville
+ * kræve en tabelombygning, fordi SQLite ikke kan ændre et CHECK på plads.
+ */
+test('et projekts kalender kan vises som en uge', async ({ page }) => {
+ const trouble = watchForTrouble(page);
+ await page.goto('/');
+
+ const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
+ await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
+ await sidebar.getByLabel('Projektnavn').fill('Ugeprojekt');
+ await sidebar.getByLabel('Projektnavn').press('Enter');
+ await expect(page.getByRole('heading', { name: 'Ugeprojekt' })).toBeVisible();
+
+ const box = page.getByLabel('Ny opgave');
+ await box.fill('male døren i dag kl 10');
+ await box.press('Enter');
+ await expect(page.getByText('male døren')).toBeVisible();
+
+ await page.getByRole('button', { name: 'Kalender', exact: true }).click();
+ await page.getByRole('button', { name: 'Uge', exact: true }).click();
+
+ // Døgnet, ikke en rude: opgaven står på sit klokkeslæt.
+ const placed = page.locator('.tevent.task').filter({ hasText: 'male døren' });
+ await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
+ await expect(placed).toContainText('10:00');
+
+ // Valget huskes — i browseren, ikke på projektet: det er skærmens bredde, der
+ // afgør det, og projektet er delt med andre skærme.
+ await page.reload();
+ await expect(page.locator('.weekgrid'), 'ugen blev ikke husket').toBeVisible();
+
+ await page.getByRole('button', { name: 'Måned', exact: true }).click();
+ await expect(page.locator('.weekgrid')).toBeHidden();
+
+ expect(trouble).toEqual([]);
+});
diff --git a/web/src/lib/components/CalendarView.svelte b/web/src/lib/components/CalendarView.svelte
index df291a9..872967b 100644
--- a/web/src/lib/components/CalendarView.svelte
+++ b/web/src/lib/components/CalendarView.svelte
@@ -64,7 +64,14 @@
events = []
} = $props();
- let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
+ // Sat til i dag og lagt til rette af effekten nedenfor.
+ //
+ // Ikke `span === 'week' ? … : …` her: den læser `span`, som den er i det
+ // øjeblik komponenten bliver til, og fanger den værdi for altid — hvilket
+ // byggeriet har advaret om lige så længe. Så længe hver visning havde sin egen
+ // komponent, gjorde det ingen forskel; det gjorde det i samme sekund, et
+ // projekts kalender fik en uge/måned-vælger, der skifter `span` på den samme.
+ let cursor = $state(new Date());
/**
* The weekday names, from Intl rather than written out.
@@ -101,13 +108,26 @@
// week with a cursor on the 1st would otherwise show the week containing the
// 1st rather than the week you were looking at, and switching back would land
// on whichever month that week happened to start in.
+ //
+ // Med én undtagelse, og den er hele grunden til at det her er en effekt og ikke
+ // en udregning: en månedsvisnings markør *er* den første i måneden. Skifter man
+ // til uge, mens man står i den indeværende måned, er den uge man kigger på ikke
+ // ugen omkring den 1. — det er den, i dag ligger i. Uden det her landede et
+ // projekts kalender på "27. jul.–2. aug." for en person, der kiggede på august.
$effect(() => {
- const aligned = startOfSpan(cursor);
+ const anchor =
+ span === 'week' && isMonthStart(cursor) && sameMonth(cursor, new Date())
+ ? new Date()
+ : cursor;
+ const aligned = startOfSpan(anchor);
// Compared by value, not identity: assigning a fresh Date on every run would
// re-trigger this effect forever, since the effect reads what it writes.
if (aligned.getTime() !== cursor.getTime()) cursor = aligned;
});
+ const isMonthStart = (d) => d.getDate() === 1;
+ const sameMonth = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
+
function iso(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js
index 6dacab5..84fb5ed 100644
--- a/web/src/lib/stores.svelte.js
+++ b/web/src/lib/stores.svelte.js
@@ -890,27 +890,55 @@ export const upcomingView = new UpcomingView();
const CALENDAR_MODES = ['week', 'month'];
class CalendarViewMode {
- mode = $state(readCalendarMode());
+ mode = $state('month');
+
+ // Nøglen som argument frem for som felt på en underklasse.
+ //
+ // Den vej blev prøvet og virkede ikke: en underklasses felter sættes *efter*
+ // basens konstruktør er kørt, så en læsning i konstruktøren brugte basens nøgle
+ // og skrivningen bagefter underklassens. Valget blev gemt ét sted og læst et
+ // andet, og en uge, man havde valgt, var en måned igen efter en genindlæsning.
+ // Et argument har ingen rækkefølge at tage fejl af.
+ constructor(storageKey = 'verdande:calendar') {
+ this.storageKey = storageKey;
+ this.mode = readCalendarMode(storageKey);
+ }
set(next) {
if (!CALENDAR_MODES.includes(next)) return;
this.mode = next;
try {
- localStorage.setItem('verdande:calendar', next);
+ localStorage.setItem(this.storageKey, next);
} catch {
// Private browsing; the choice simply will not persist.
}
}
}
-function readCalendarMode() {
+function readCalendarMode(key) {
if (typeof localStorage === 'undefined') return 'month';
- const stored = localStorage.getItem('verdande:calendar');
+ const stored = localStorage.getItem(key);
return CALENDAR_MODES.includes(stored) ? stored : 'month';
}
export const calendarView = new CalendarViewMode();
+/**
+ * Måned eller uge på et projekts kalender.
+ *
+ * Sin egen nøgle, af samme grund som Kalenders er sin egen: det er tre steder med
+ * hver sit spørgsmål. Kommende åbner på en liste over de næste syv dage, Kalender
+ * *er* gitteret, og et projekts kalender er en del af en side, der også kan være
+ * en liste og et board. At dele værdien ville betyde, at man valgte en uge ét sted
+ * og fandt et andet ændret.
+ *
+ * Og ikke på projektet. `view_mode` er projektets — den er delt med alle, der kan
+ * se det — mens uge eller måned er et spørgsmål om, hvor bred skærmen er, foran
+ * hvilken der sidder én person. Det er den samme grund, som sidebjælkens bredde
+ * ligger i localStorage af.
+ */
+export const projectCalendarView = new CalendarViewMode('verdande:project-calendar');
+
/**
* Whether the lists show what has already been finished.
*
diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte
index 37d0b7e..7753783 100644
--- a/web/src/routes/projekt/[id]/+page.svelte
+++ b/web/src/routes/projekt/[id]/+page.svelte
@@ -3,7 +3,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
- import { app, completedView, projectName } from '$lib/stores.svelte.js';
+ import { app, completedView, projectName, projectCalendarView } from '$lib/stores.svelte.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { eventName, eventDetail } from '$lib/events.js';
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
@@ -786,7 +786,26 @@
onsectionadded={(section) => (sections = [...sections, section])}
/>
{:else if mode === 'calendar'}
-